Files
mesh-catalog/modules/messenger/cmd/messenger/answers.go
T
jochen 82475691b2 issue 797: messenger gains the tell verb for a plain notice
ADR 0380 §8: the operator channel's notify is shaped for conditions (a key that
stays open, escalation, a plain rule that rewrites a subject), so a module has
no way to say a plain notice such as new mail. tell sends a headline and one or
two lines at once to every channel, bypassing routing, the burst window and the
walk hold; it offers Details like any message, is kept in the history, but its
record is marked cleared at once so it never stays open. Its cap (10 a channel
an hour) is its own, apart from notify's and the conditions'.
2026-10-15 17:37:15 +02:00

302 lines
10 KiB
Go

package main
// What the operator needs to do, and answering from a message (novox/hq ADR 0253, ADR 0258, ADR 0259).
//
// - **Every explanation opens with a verdict**, written by the controller: "Nothing for you to do." or
// "Needs you:" and one thing the operator can do themselves.
// - **A warning that needs nothing is not sent.** It is kept open here and in the controller's
// conditions, the history says it was kept quiet, and it is said when it needs the operator — urgent
// at its bound, its words changed to "Needs you:", or an ask about it arrived — and once after a day in
// any case (QuietRemindAfter), so nothing stays silent for ever.
// - **The router performs nothing** (ADR 0259). A message offers the answers of the ask about it, which
// its asker performs on a warrant (asks.go), and Details, which performs nothing and is shown where it
// was chosen. The condition's own actions are never called from here: the controller asks for them.
// - **A ticket is an answer's one-time ticket**: made for one message on one channel kind, good for
// TokenFor, used once; refused for a record cleared or silenced, used, expired, from another kind or
// unknown — and the refusal is shown where the answer came from. One answer is acted on once,
// whatever its redelivery.
// - **Every message still shown keeps its Details** (issue 371): a newer message about the same problem
// makes a ticket of its own and leaves the older ones standing, so Details works on each message once.
// A record keeps at most MostDetails of them on a kind, and none older than TokenFor; a ticket dropped
// by that bound is refused in words that say to tap Details on the newest message.
import (
"encoding/json"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Action is one answer a condition names: a label, and the seat verb its producer calls when it is chosen.
// Kept as the controller says it; the router offers a condition's answers only through an ask about it.
type Action struct {
Label string `json:"label"`
Verb string `json:"verb"`
Machine string `json:"machine,omitempty"`
Arguments map[string]string `json:"arguments,omitempty"`
}
// Grant is one Details ticket: the kind it was shown on, when it was made, and whether it was used.
type Grant struct {
Index int `json:"index"`
Kind string `json:"kind,omitempty"`
Issued time.Time `json:"issued"`
Used bool `json:"used,omitempty"`
}
const (
// DetailsLabel is the answer every condition's message offers.
DetailsLabel = "Details"
// MostActions bounds what a message offers, Details included.
MostActions = 4
// TokenFor is how long a Details ticket is good for.
TokenFor = 24 * time.Hour
// MostDetails bounds the Details tickets one record keeps on one channel kind: one per message about it
// still shown there; beyond it the oldest is forgotten (issue 371).
MostDetails = 8
// KeptEvents is how many answers are remembered, so a redelivered one is acted on once.
KeptEvents = 200
)
// NoOpenMessage is the refusal of a ticket no open message holds: its problem ended, or so many newer
// messages about it were sent that this older one's Details was forgotten.
const NoOpenMessage = "it belongs to no open message; the problem may have ended, or this message is older " +
"than the last few about it: tap Details on the newest one"
func sameActions(a, b []Action) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
// needsNothing is a record the operator need not hear of now: words of its own that open "Nothing for
// you to do.", only a warning, and nothing asked about it. A record without words is said.
func needsNothing(r *Record) bool {
return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition" && r.Ask == ""
}
// choices are the answers a record's next message offers on one kind: the open ask's options offered
// there, and Details for a condition, with a ticket of its own. The tickets of the record's earlier messages
// stand (issue 371): each message keeps a Details of its own, used once, until it is TokenFor old or
// MostDetails newer ones on that kind push it out.
func (h *Holder) choices(r *Record, kind string) []Choice {
var out []Choice
if rec := h.openAsk(r); rec != nil {
out = h.offers(rec, kind)
}
// A condition offers Details so the operator can read more; a tell does too (ADR 0380 §8: "Details is
// offered as for any message") — it is its own message, so Details just shows it again.
if (r.Origin != "condition" && r.Origin != "tell") || len(out) >= MostActions {
return out
}
now := h.Now()
h.mu.Lock()
defer h.mu.Unlock()
if r.Tokens == nil {
r.Tokens = map[string]Grant{}
}
pruneGrants(r.Tokens, kind, now, MostDetails-1)
t := ticket()
r.Tokens[t] = Grant{Index: -1, Kind: kind, Issued: now}
return append(out, Choice{Token: t, Label: DetailsLabel})
}
// pruneGrants forgets every ticket older than TokenFor, and keeps at most keep of kind's, the newest.
func pruneGrants(grants map[string]Grant, kind string, now time.Time, keep int) {
var mine []string
for t, g := range grants {
switch {
case now.Sub(g.Issued) > TokenFor:
delete(grants, t)
case g.Kind == kind:
mine = append(mine, t)
}
}
if len(mine) <= keep {
return
}
sort.Slice(mine, func(i, j int) bool {
a, b := grants[mine[i]], grants[mine[j]]
if !a.Issued.Equal(b.Issued) {
return a.Issued.Before(b.Issued)
}
return mine[i] < mine[j]
})
for _, t := range mine[:len(mine)-keep] {
delete(grants, t)
}
}
// Chosen takes an answer a channel of a kind said it was given: an ask's option is judged and, when it
// stands, closes the ask with a warrant for its asker (asks.go); Details are shown where they were asked.
// Anything else is refused, and said there. The same answer is acted on once.
func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) {
// The controller's root-free word is asked before the work is taken (the confirmation review of 2026-10-09):
// up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer,
// and judged with in it.
root := h.rootWordFor(kind)
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
if c.ID != "" {
if outcome, done := h.answered[c.ID]; done {
h.mu.Unlock()
return outcome, nil
}
}
key := ""
if h.tickets != nil {
key = h.tickets[c.Ticket]
}
now := h.Now()
var r *Record
var g Grant
for _, rec := range h.open {
if gr, ok := rec.Tokens[c.Ticket]; ok {
r, g = rec, gr
}
}
h.mu.Unlock()
if !c.At.IsZero() && now.Sub(c.At) > FreshFor {
// Replayed from the stream long after it was given: history, never an answer.
h.Logf("[messenger] an answer on %s from %s is %s old and was not acted on", kind, c.At.Format(time.RFC3339), roughly(now.Sub(c.At)))
h.remember(c.ID, "too old")
return "too old", errorf("it was given %s ago", roughly(now.Sub(c.At)))
}
if key != "" {
out, err := h.judge(kind, c, key, root)
h.remember(c.ID, out)
return out, err
}
refuse := func(why string) (string, error) {
out, err := h.refuseChoice(kind, c, why)
h.remember(c.ID, out)
return out, err
}
switch {
case r == nil:
return refuse(NoOpenMessage)
case !r.Cleared.IsZero():
return refuse("the problem it answered has ended")
case r.silenced(now):
return refuse("the problem it answered is silenced")
case g.Used:
return refuse("it was already chosen once")
case now.Sub(g.Issued) > TokenFor:
return refuse("it is more than a day old")
case g.Kind != "" && g.Kind != kind:
return refuse("it was not offered here")
}
h.mu.Lock()
g.Used = true
r.Tokens[c.Ticket] = g
h.mu.Unlock()
headline, _ := wordsOf(r)
// Each line held to the content rule on its own: a line that carries what may not leave is said
// withheld, and the rest is still shown.
var lines []string
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
if refusal, ok := Check(l); !ok {
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
}
lines = append(lines, l)
}
err := h.showOn(kind, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n"), Reply: c.ID})
h.record(Sent{At: now, Channel: kind, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
h.remember(c.ID, "shown")
h.persist(r)
return "shown", err
}
func (h *Holder) remember(event, outcome string) {
if event == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if h.answered == nil {
h.answered = map[string]string{}
}
if len(h.answered) >= KeptEvents {
for k := range h.answered {
delete(h.answered, k)
break
}
}
h.answered[event] = outcome
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
func outcomeOf(err error) string {
if err != nil {
return "failed: " + err.Error()
}
return "sent"
}
func firstLineOf(s string) string {
s, _, _ = strings.Cut(s, "\n")
if len(s) > 160 {
s = s[:160] + "…"
}
return s
}
// Failed takes a channel's word that a message did not arrive: the channel is said to be failing, and the
// record it carried is tried again — unless the failure is permanent, which is said and not repeated.
func (h *Holder) Failed(kind string, f asks.Failed) {
h.work.Lock()
defer h.work.Unlock()
h.noteChannel(kind, errorf("%s", f.Why))
h.mu.Lock()
var r *Record
for _, rec := range h.open {
if rec.Sent[kind] == f.Handle && f.Handle != "" {
r = rec
}
}
if r != nil && r.Cleared.IsZero() && !f.Permanent {
delete(r.Sent, kind)
if r.Pending == "" {
r.Pending = "raised"
}
}
desk := h.Bench != nil && h.Bench.roleOf(kind) == RoleDesk
h.mu.Unlock()
// A warning the desk could not show goes to the channel that reaches the operator away, as it did when the
// desk was the router's own (the correctness review of 2026-10-08).
if desk && r != nil && r.Cleared.IsZero() && r.Severity != Urgent && h.Away != nil && h.Away.Ready() == nil {
h.enqueue(h.Away.Name(), item{Key: r.Key, What: "raised", Severity: r.Severity, At: h.Now()})
h.mu.Lock()
r.Pending = ""
h.mu.Unlock()
h.flush(h.Now(), false)
}
outcome := "failed: " + f.Why
if f.Permanent {
outcome = "failed for good: " + f.Why
}
h.record(Sent{At: h.Now(), Channel: kind, Key: keyOrNone(r), What: "delivery", Outcome: outcome})
if r != nil {
h.persist(r)
}
}
func keyOrNone(r *Record) string {
if r == nil {
return "(none)"
}
return r.Key
}