diff --git a/modules/messenger/README.md b/modules/messenger/README.md index 0841debd..3932f4c2 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -1,20 +1,44 @@ # messenger -The holder of the `operator-channel` seat: how the mesh tells its operator what it noticed (novox/hq -to-be 45 §5, ADR 0227, research 028 — the minimal form, Q1a, Q5a, Q8). +The holder of the `operator-channel` seat: the router (novox/hq to-be 45 §5, to-be 46, ADR 0227, ADR 0234, +ADR 0259). It decides what is said to the operator and on which channel, holds every ask a module makes of +the operator, judges each answer and issues the **warrant** its asker acts on. It performs nothing, holds no +channel's client and no channel's secret. -- **Declares and holds `operator-channel`**, held once for the mesh, serving `open`, `history` and - `notify`. -- **Reads the controller's open conditions** (`seat:mesh-controller.conditions`) — the state it - believes, before and beside the events. -- **Consumes the controller's condition events** — `mesh-controller.condition-raised`, `-changed`, - `-cleared` — and decides what is sent. The controller calls nobody. The events are read in one - place, `cmd/messenger/condition.go`, which states every assumption it makes about their shape. -- **Two channels:** Telegram (a bot to the operator's chat) and the desktop notifier — the - `node-notifier` seat's `send` verb on the machine the operator sits at (ADR 0208), asked through the - mesh. Nothing new runs on that machine. -- **Keeps its open messages in its own state** (`open`; the recent sends in `sent`), so a restart - forgets nothing (ADR 0201). +- **Declares three seats:** `operator-channel` (held here: `open`, `history`, `notify`, `asks`; an ask and + its cancel accepted under the asker's own name, the warrant emitted to that asker alone; the asks kept as + a record each asker reads under its own name), and the two kinded benches its channels hold, `channel` + (`show`, `edit`, `send`, each under the holder's kind) and `intake` (`choice`, `link`, `failed`, `standing`, + and the proof `code`, request and reply only). Only `channel` and `intake` may be kinded. +- **Reads the controller's open conditions** (`seat:mesh-controller.conditions`) and **consumes its + condition events**; the events are read in one place, `cmd/messenger/condition.go`. +- **The channels are modules of their own**: `telegram` (kind `telegram`, reaches the operator away, proves + its sender) and `desk-channel` (kind `desktop`, the machines' notifiers, proves nothing). Which kinds exist + and what each promises is the controller's word, in this module's membership; whether each can send now + is the channel's own, said on `intake.standing`. +- **Keeps its state on the bus** (ADR 0201): `open` and `sent` (the messages), `asks` (every ask, closed ones + for 30 days), `identities` (the operator's linked accounts, one per kind, and a link asked for). + +## Asks, answers and warrants (novox/hq ADR 0259) + +- **An ask** arrives on `mesh.seat.operator-channel.accept.ask.`; the asker is the subject's last + token, which the bus grants that module alone. It is checked (the asks contract, the content rule, at + most three open per asker) and recorded before its work is taken; a refusal is said to the asker on its + `decided` subject, in words. +- **It is offered on every kind that can carry one of its answers**: an acknowledgement wherever a choice + can be made; an approval only where the kind proves its sender and an account is linked; nothing that + destroys until an authenticator-app code exists. Each option has a one-time ticket per kind; the words + say where an answer not offered here can be given ("Release or Stop: answer on Telegram, or from the mesh + MCP server."). An ask about a condition is shown on that condition's message. +- **An answer is judged in order**, refused at the first failure and said where it was given: the ticket is + known, unused and for this kind; the ask is open and unexpired; the sender is the linked account where + the kind proves its sender; the kind's capabilities meet the level; an approval comes from an account + linked an hour or more. The ask is then closed by compare-and-set — a second answer loses — and the + warrant published to the asker alone. A warrant the bus did not take is published again every minute. +- **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would. +- **Linking** (`/start` to the bot): a six-digit code is shown on the desk for ten minutes, the operator + sends it to the bot, and the account is linked; it acknowledges at once and approves after an hour. + Every link and unlink is said on every channel. `messenger_unlink` unlinks. ## What is sent, and when @@ -49,18 +73,10 @@ operator can do themselves. **A warning that needs nothing is not sent**: it sta controller's conditions, the history says it was kept quiet, and it is said the moment it needs the operator (urgent at its bound, or its words now say "Needs you:"). An urgent condition is always said. -**A notification offers answers** (`cmd/messenger/answers.go`), limited by novox/hq ADR 0258: **a desk -click performs only an acknowledgement**. Any process of the operator's account can choose a dunst action -(`dunstctl action`), so a click proves nothing about who chose. Until answers are authorised (to-be 46 -phase 5) the only answers offered and performed are **Details** and **Silence for a week** (the allow-list -`answerable`); release, stop, start and restart are said in words ("Needs you: release it, or stop it.") -and offered as no answer. Each answer is a token made for one message of one condition: bound to the -machines it was shown on, good for a day, used once, refused for a condition cleared or silenced, and one -event is acted on once. A refused answer is said on the desk where it was chosen, never dropped. When the -dunst bundle says it started (`dunst.started`), the open notifications that offer answers are shown there -again with fresh tokens. A silence is recorded with the cause `operator-answer`, which the hand-act log -does not count as a repair. A warning kept quiet is also said once after a day. Telegram shows the words -but no buttons. +**A message offers the answers of the ask about it, and Details** (`cmd/messenger/answers.go`). The router +calls no verb of a condition's: the controller asks for the answers a condition names, and performs the one +chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen. A +warning kept quiet is also said once after a day. | When | What | |---|---| @@ -71,9 +87,8 @@ but no buttons. | cleared and raised again within 10 min | the same message, edited back to open — not a new one | | silenced | nothing, its clearing included | -- **Routing:** urgent to Telegram and the desktop; warning to the desktop when a session there - answers, otherwise to Telegram. The notifier answering is how "the operator's session is there" is - read until presence is decided (research 028 Q4). +- **Routing:** urgent to every kind that can send; a warning to the desk where it can send, otherwise to + the channel that reaches the operator away; an ask to every kind that carries one of its answers. - **Bursts are one message.** What is to be said on a channel is held **30 s** from the first and goes out together: alone, as itself; several, as one digest (`WARNING: 5 new warnings`, a line each). An urgent message goes at once when nothing went out on its channel in the last 30 s. @@ -91,21 +106,13 @@ but no buttons. when the open conditions were last read, why they could not be, and how many events were taken as history. An event that cannot be read is refused by name, counted, and told to the operator once an hour. -- **No answering back.** Acknowledging is `conditions silence`, through the mesh. ## What the operator gives -Nothing is sent until these are given; `messenger_status` says which is missing. - -1. **The bot token**, as this module's own secret: `secret accept messenger telegram-token`, - then push that machine. A value the mesh minted because none was accepted is recognised as not a - bot token and said so. -2. **The chat id**, as a setting: `settings` for `messenger` with `{"telegram-chat-id": ""}`. -3. **The desktop machines**, as a setting: `{"desktop-machines": ["", …]}`. - Without it, warnings go to Telegram. -4. **The time zone**, as a setting: `{"time-zone": ""}`. Every time a message says is - said in it; not given, the zone of the machine this runs on. A name this machine does not know is - logged and that zone used instead. +1. **The time zone**, as a setting: `{"time-zone": ""}`. Every time a message says is + said in it; not given, the zone of the machine this runs on. +2. **The channels**: assign `telegram` and `desk-channel` (each says in its README what it needs). Linking + an account as the operator is done on the channel, never by a setting. ## Tools @@ -114,15 +121,18 @@ Nothing is sent until these are given; `messenger_status` says which is missing. | `operator-channel.open` | what is open now, urgent first, with where it went, silenced, reminded, held, refused, unsent | | `operator-channel.history` | what was said lately, and the refusals | | `operator-channel.notify` | a message from a module using the seat: key, severity, summary, and best a plain `headline`, `explanation` and `resolved` line; `clear` to end it | -| `messenger_status` | whether the operator can be reached and why not; `check` asks Telegram whether the token works | -| `messenger_recent` | the recent sends, edits, digests and failures | -| `messenger_test` | a test message now, to telegram, desktop or both | +| `operator-channel.asks` | the asks held, open first: asker, state, where offered, the warrant, whether the asker was told | +| `messenger_status` | whether the operator can be reached and why not, per channel kind | +| `messenger_recent` | the recent sends, edits, digests, answers and failures | +| `messenger_test` | a test message now, to one kind or every one | | `messenger_check` | whether some words may leave the mesh | +| `messenger_identities` | the accounts linked as the operator, and from when each approves | +| `messenger_unlink` | unlink a kind's account, said on every channel | ## Where it runs -Held once for the mesh: assign it to one machine whose tool runtime runs as root, since its secret -and settings are root's files at 0600 (as every runtime-carried module's are). +Held once for the mesh, on the control node: its settings are root's files at 0600 (as every +runtime-carried module's are). It holds no secret. ## Not yet diff --git a/modules/messenger/cmd/messenger/answers.go b/modules/messenger/cmd/messenger/answers.go index 70639300..168707e4 100644 --- a/modules/messenger/cmd/messenger/answers.go +++ b/modules/messenger/cmd/messenger/answers.go @@ -1,41 +1,31 @@ package main -// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253, as -// limited by ADR 0258). +// 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, or its words changed to "Needs you:" (an event, or a reading of the state) — and once -// after a day in any case (QuietRemindAfter), so nothing stays silent for ever. -// - **A desk click performs only an acknowledgement** (ADR 0234 §8). Any process of the operator's account -// can choose a notification's action (`dunstctl action`), so a click proves nothing about who chose; -// until answers are authorised (to-be 46 phase 5) the only answers offered and performed are Details -// and silencing a condition. Everything else — release, stop, start, restart — is said in words and -// offered as no answer. The allow-list is `answerable`; an action outside it is never offered, and a -// token for one is refused. -// - **A token is an answer's one-time ticket**: made for one message of one condition, bound to the -// machines it was shown on, valid for TokenFor, used once. An answer for a condition cleared or -// silenced, from another machine, used, expired or unknown, is refused — and the refusal is shown where -// the answer came from, so a click is never silently dead. One event is acted on once, whatever its -// redelivery. -// - **A notifier that restarted lost the answers it was waiting for**; when it says it started, the open -// notifications that offer answers are shown there again with fresh tokens (Reoffer). +// 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. import ( - "crypto/rand" - "encoding/hex" "encoding/json" - "fmt" - "sort" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/asks" ) -// Action is one answer a condition offers: a label, and the seat verb called with these arguments when -// it is chosen. Machine is set for a seat every machine holds. An argument "why" given empty is filled -// with the operator's words. +// 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"` @@ -43,45 +33,25 @@ type Action struct { Arguments map[string]string `json:"arguments,omitempty"` } -// Grant is one token's answer: the action (or -1, Details), when it was made, and whether it was used. +// 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 notification offers. + // DetailsLabel is the answer every condition's message offers. DetailsLabel = "Details" - // MostActions bounds what a notification offers, Details included. + // MostActions bounds what a message offers, Details included. MostActions = 4 - // TokenFor is how long an answer's token is good for. + // TokenFor is how long a Details ticket is good for. TokenFor = 24 * time.Hour - // KeptEvents is how many answered events are remembered, so a redelivered one is acted on once. + // KeptEvents is how many answers are remembered, so a redelivered one is acted on once. KeptEvents = 200 ) -// answerable is the allow-list of answers a desk click performs (ADR 0258): silencing a condition, an -// acknowledgement any granted principal may already make. Nothing else, until answers are authorised. -func answerable(a Action) bool { - if a.Verb != "mesh-controller.conditions" || a.Machine != "" || a.Arguments["silence"] == "" { - return false - } - for k := range a.Arguments { - switch k { - case "silence", "for", "why", "cause": - default: - return false - } - } - return true -} - -// answerableFor is answerable for this record: a silence of its own condition, and of no other. -func answerableFor(r *Record, a Action) bool { - return answerable(a) && a.Arguments["silence"] == r.Key -} - func sameActions(a, b []Action) bool { x, _ := json.Marshal(a) y, _ := json.Marshal(b) @@ -89,82 +59,79 @@ func sameActions(a, b []Action) bool { } // needsNothing is a record the operator need not hear of now: words of its own that open "Nothing for -// you to do.", and only a warning. A record without words (an older controller, a notice) is said. +// 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" + return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition" && r.Ask == "" } -// choices are the answers a record's next message offers, each with a fresh token; the tokens of its -// earlier messages end with them. -func (h *Holder) choices(r *Record) []Choice { - if r.Origin != "condition" { - return nil +// 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 fresh ticket replacing that kind's earlier one. +func (h *Holder) choices(r *Record, kind string) []Choice { + var out []Choice + if rec := h.openAsk(r); rec != nil { + out = h.offers(rec, kind) + } + if r.Origin != "condition" || len(out) >= MostActions { + return out } now := h.Now() h.mu.Lock() defer h.mu.Unlock() - r.Tokens = map[string]Grant{} - var out []Choice - for i, a := range r.Actions { - if !answerableFor(r, a) || len(out) == MostActions-1 { - continue - } - t := token() - r.Tokens[t] = Grant{Index: i, Issued: now} - out = append(out, Choice{Token: t, Label: a.Label}) + if r.Tokens == nil { + r.Tokens = map[string]Grant{} } - t := token() - r.Tokens[t] = Grant{Index: -1, Issued: now} + for t, g := range r.Tokens { + if g.Kind == kind { + delete(r.Tokens, t) + } + } + t := ticket() + r.Tokens[t] = Grant{Index: -1, Kind: kind, Issued: now} return append(out, Choice{Token: t, Label: DetailsLabel}) } -func token() string { - var b [12]byte - _, _ = rand.Read(b[:]) - return hex.EncodeToString(b[:]) -} - -// shownOn are the machines a record's desktop message was shown on. -func shownOn(r *Record) []string { - var out []string - for _, part := range strings.Split(r.Sent["desktop"], ",") { - if m, _, ok := strings.Cut(part, "="); ok { - out = append(out, m) - } - } - return out -} - -// Chosen takes an answer a channel handed back: the token of a choice, on a machine, in an event. It -// performs an allowed answer, shows what came of it — or why it was refused — where it was chosen, and -// answers that in words. The same event is acted on once. -func (h *Holder) Chosen(channel, machine, tok, event string) (string, error) { +// 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) { h.work.Lock() defer h.work.Unlock() h.mu.Lock() h.init() - if event != "" { - if outcome, done := h.answered[event]; done { + 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[tok]; ok { + 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) + h.remember(c.ID, out) + return out, err + } refuse := func(why string) (string, error) { - h.Logf("[messenger] an answer on %s from %s was refused: %s", channel, machine, why) - h.remember(event, "refused: "+why) - h.record(Sent{At: now, Channel: channel, Key: keyOf(r), What: "answer", Outcome: "refused: " + why}) - _, _ = h.showOn(channel, machine, Message{Title: "That answer was not taken", - Body: capital(why) + ". Nothing was done.", Quiet: true}) - return "refused", errorf("%s", why) + out, err := h.refuseChoice(kind, c, why) + h.remember(c.ID, out) + return out, err } switch { case r == nil: @@ -177,119 +144,28 @@ func (h *Holder) Chosen(channel, machine, tok, event string) (string, error) { return refuse("it was already chosen once") case now.Sub(g.Issued) > TokenFor: return refuse("it is more than a day old") - case channel == "desktop" && !contains(shownOn(r), machine): - return refuse("it was not shown on " + machine) - case g.Index >= len(r.Actions) || (g.Index >= 0 && !answerableFor(r, r.Actions[g.Index])): - return refuse("it is not an answer a notification may give") + case g.Kind != "" && g.Kind != kind: + return refuse("it was not offered here") } h.mu.Lock() g.Used = true - r.Tokens[tok] = g + r.Tokens[c.Ticket] = g h.mu.Unlock() headline, _ := wordsOf(r) - if g.Index == -1 { - // 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) + // 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)" } - _, err := h.showOn(channel, machine, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")}) - h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "details", Outcome: outcomeOf(err)}) - h.remember(event, "shown") - h.persist(r) - return "shown", err + lines = append(lines, l) } - a := r.Actions[g.Index] - args := map[string]any{} - for k, v := range a.Arguments { - args[k] = v - } - if v, ok := a.Arguments["why"]; ok && v == "" { - args["why"] = fmt.Sprintf("chosen on the %s notification %q on %s (a desk click: whoever was at the "+ - "operator's session)", channel, headline, machine) - } - var err error - if h.Ask == nil { - err = errorf("this holder cannot call the mesh") - } else { - var raw json.RawMessage - raw, err = h.Ask("seat:"+a.Verb, args) - if err == nil { - err = refusalIn(raw) - } - } - outcome := "done" - title := a.Label + ": done. " + headline - body := "Chosen at " + now.In(h.zone()).Format("15:04") + "." - if err != nil { - outcome = "failed: " + err.Error() - title = "Could not " + strings.ToLower(a.Label) + ": " + headline - body = "The mesh refused it: " + firstLineOf(err.Error()) - if _, ok := Check(body); !ok { - body = "The mesh refused it; the reason carries what may not be shown here." - } - } - h.mu.Lock() - r.Answered = a.Label + ": " + outcome - sent := r.Sent[channel] - h.mu.Unlock() + 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) - m := Message{Title: title, Body: body, Quiet: err == nil} - if ch := h.channelNamed(channel); ch != nil && sent != "" && ch.CanEdit() { - if e := ch.Edit(sent, m); e != nil { - _, _ = h.showOn(channel, machine, m) - } - } else { - _, _ = h.showOn(channel, machine, m) - } - h.remember(event, outcome) - h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "answer " + a.Label, Outcome: outcome}) - h.Logf("[messenger] %s was chosen for %s on %s (%s): %s", a.Label, r.Key, channel, machine, outcome) - return outcome, err -} - -// Reoffer shows again, on a machine whose notifier restarted and so lost the answers it waited for, every -// open message shown there that offers answers — with fresh tokens, in place of the old one. -func (h *Holder) Reoffer(machine string) int { - h.work.Lock() - defer h.work.Unlock() - h.mu.Lock() - h.init() - var recs []*Record - for _, r := range h.open { - if r.Cleared.IsZero() && len(r.Tokens) > 0 && contains(shownOn(r), machine) { - recs = append(recs, r) - } - } - h.mu.Unlock() - sort.Slice(recs, func(i, j int) bool { return recs[i].Key < recs[j].Key }) - n := 0 - for _, r := range recs { - if r.silenced(h.Now()) || h.Desktop == nil { - continue - } - m := h.say(r, "raised") - id := "" - for _, part := range strings.Split(r.Sent["desktop"], ",") { - if strings.HasPrefix(part, machine+"=") { - id = part - } - } - if err := h.Desktop.Edit(id, m); err != nil { - h.Logf("[messenger] could not offer %s again on %s: %v", r.Key, machine, err) - continue - } - h.persist(r) - n++ - } - if n > 0 { - h.Logf("[messenger] the notifier on %s started again: %d message(s) with answers shown there again", machine, n) - } - return n + return "shown", err } func (h *Holder) remember(event, outcome string) { @@ -310,13 +186,6 @@ func (h *Holder) remember(event, outcome string) { h.answered[event] = outcome } -func keyOf(r *Record) string { - if r == nil { - return "(none)" - } - return r.Key -} - func contains(list []string, s string) bool { for _, x := range list { if x == s { @@ -341,43 +210,39 @@ func firstLineOf(s string) string { return s } -// refusalIn reads a tool's answer for a refusal: an error result is an error. -func refusalIn(raw json.RawMessage) error { - var wrapped struct { - Content []struct { - Text string `json:"text"` - } `json:"content"` - IsError bool `json:"isError"` - Error string `json:"error"` - } - if json.Unmarshal(raw, &wrapped) == nil { - if wrapped.IsError && len(wrapped.Content) > 0 { - return errorf("%s", wrapped.Content[0].Text) - } - if wrapped.Error != "" { - return errorf("%s", wrapped.Error) +// 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 } } - return nil + if r != nil && r.Cleared.IsZero() && !f.Permanent { + delete(r.Sent, kind) + if r.Pending == "" { + r.Pending = "raised" + } + } + h.mu.Unlock() + 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 (h *Holder) channelNamed(name string) Channel { - for _, ch := range h.channels() { - if ch.Name() == name { - return ch - } +func keyOrNone(r *Record) string { + if r == nil { + return "(none)" } - return nil -} - -// showOn shows a message of the holder's own on one channel, on one machine where the channel has several. -func (h *Holder) showOn(channel, machine string, m Message) (string, error) { - ch := h.channelNamed(channel) - if ch == nil { - return "", errorf("no channel %s", channel) - } - if d, ok := ch.(*Desktop); ok && machine != "" { - return d.showOne(machine, m) - } - return ch.Send(m) + return r.Key } diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index 8a5f28a6..41acebc1 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -2,298 +2,592 @@ package main import ( "encoding/json" - "fmt" "strings" "testing" "time" + + "git.novox.be/novox/mesh-sdk/go/asks" ) -// The review of 2026-10-08 (novox/hq ADR 0258): a desk click performs only an acknowledgement, and an -// answer's token is a one-time ticket bound to its condition and its machine. +// The router of novox/hq ADR 0259: it holds every ask, judges each answer and issues a warrant; it +// performs nothing. These tests hold it to every rule of the record's §4, §5 and §7. -// answering is a holder with the failed-units condition shown on the desktop of g14, and the calls the -// holder makes recorded. -func answering(t *testing.T, actions ...Action) (*Holder, *fakeChannel, *clock, *[]string, Condition) { - t.Helper() - h, _, dt, c, _ := newHolder(t) - h.Telegram = nil - asked := &[]string{} - h.Ask = func(address string, body any) (json.RawMessage, error) { - raw, _ := json.Marshal(body) - *asked = append(*asked, address+" "+string(raw)) - return json.RawMessage(`{"ok":true}`), nil - } - e := examples[3] - if len(actions) > 0 { - e.actions = actions - } - k, err := DecodeCondition(EventRaised, e.event(c.now(), false)) - if err != nil { - t.Fatal(err) - } - h.Condition(EventRaised, k) - settle(h, c) - if len(dt.sends) != 1 { - t.Fatalf("desktop sends %d", len(dt.sends)) - } - return h, dt, c, asked, k +// memAsks is the router's state in memory, with the compare-and-set the bus gives. +type memAsks struct { + asks map[string]AskRecord + revs map[string]uint64 + ids map[string]Identity + links map[string]PendingLink + rev uint64 } -func tokenOf(t *testing.T, m Message, label string) string { +func newMemAsks() *memAsks { + return &memAsks{asks: map[string]AskRecord{}, revs: map[string]uint64{}, ids: map[string]Identity{}, + links: map[string]PendingLink{}} +} + +func clone(r AskRecord) AskRecord { + raw, _ := json.Marshal(r) + var out AskRecord + _ = json.Unmarshal(raw, &out) + return out +} + +func (m *memAsks) GetAsk(k string) (*AskRecord, uint64, error) { + r, ok := m.asks[k] + if !ok { + return nil, 0, nil + } + c := clone(r) + return &c, m.revs[k], nil +} +func (m *memAsks) CreateAsk(k string, r AskRecord) (uint64, error) { + if _, ok := m.asks[k]; ok { + return 0, ErrChanged + } + m.rev++ + m.asks[k], m.revs[k] = clone(r), m.rev + return m.rev, nil +} +func (m *memAsks) UpdateAsk(k string, r AskRecord, rev uint64) (uint64, error) { + if m.revs[k] != rev { + return 0, ErrChanged + } + m.rev++ + m.asks[k], m.revs[k] = clone(r), m.rev + return m.rev, nil +} +func (m *memAsks) AskKeys() ([]string, error) { + var out []string + for k := range m.asks { + out = append(out, k) + } + return out, nil +} +func (m *memAsks) GetIdentity(k string) (*Identity, error) { + id, ok := m.ids[k] + if !ok { + return nil, nil + } + return &id, nil +} +func (m *memAsks) PutIdentity(k string, id Identity) error { m.ids[k] = id; return nil } +func (m *memAsks) DeleteIdentity(k string) error { delete(m.ids, k); return nil } +func (m *memAsks) IdentityKeys() ([]string, error) { + var out []string + for k := range m.ids { + out = append(out, k) + } + return out, nil +} +func (m *memAsks) GetLink(kind string) (*PendingLink, error) { + l, ok := m.links[kind] + if !ok { + return nil, nil + } + return &l, nil +} +func (m *memAsks) PutLink(kind string, l PendingLink) error { m.links[kind] = l; return nil } +func (m *memAsks) DeleteLink(kind string) error { delete(m.links, kind); return nil } + +// told is what the router published to askers, by subject. +type told struct{ by map[string][]asks.Warrant } + +func (w *told) of(asker string) []asks.Warrant { return w.by[asks.DecidedSubject(asker)] } + +// newRouter is a router with two kinds: telegram, which reaches the operator away and verifies its sender, +// and the desk, which does neither — as the controller would issue them from the claims. +func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memAsks, *told) { t.Helper() + h, tg, dt, c, _ := newHolder(t) + store := newMemAsks() + w := &told{by: map[string][]asks.Warrant{}} + bench := NewBench(c.now) + bench.SetKinds([]KindInfo{ + {Kind: "telegram", Module: "telegram", Capabilities: []string{"deliver", "reaches-away", "choice", "edit", "verified-sender"}}, + {Kind: "desktop", Module: "desk-channel", Capabilities: []string{"deliver", "choice", "edit"}}, + }) + bench.Stand("telegram", Standing{Ready: true, At: c.now()}) + bench.Stand("desktop", Standing{Ready: true, At: c.now()}) + h.Bench, h.Asks = bench, store + h.Zone = func() *time.Location { return time.UTC } + h.Publish = func(subject string, body any, id string) error { + if wt, ok := body.(asks.Warrant); ok { + w.by[subject] = append(w.by[subject], wt) + } + return nil + } + return h, tg, dt, c, store, w +} + +// stand keeps the channels' word fresh as the clock moves. +func stand(h *Holder, c *clock) { + h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()}) + h.Bench.Stand("desktop", Standing{Ready: true, At: c.now()}) +} + +func heldAsk(c *clock, id string) asks.Ask { + return asks.Ask{ID: id, Headline: "Delivery of hq held for 36 hours", + Explanation: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours.", + Who: asks.Operator, Expires: c.now().Add(2 * time.Hour), OnExpiry: "the delivery stays held", + Options: []asks.Option{ + {ID: "release", Label: "Release", Does: "the delivery goes on", Level: asks.Approve}, + {ID: "stop", Label: "Stop", Does: "the delivery ends", Level: asks.Approve}, + {ID: "silence", Label: "Silence for a week", Does: "nothing more is said of it for a week", Level: asks.Acknowledge}, + }} +} + +func submit(t *testing.T, h *Holder, asker string, a asks.Ask) { + t.Helper() + raw, _ := json.Marshal(a) + if err := h.TakeAsk("ask", asker, raw); err != nil { + t.Fatal(err) + } +} + +func link(h *Holder, store *memAsks, c *clock, identity string, at time.Time) { + store.ids[identityKey("telegram", identity)] = Identity{Kind: "telegram", Identity: identity, Display: "Jo", + Linked: at, ApproveFrom: at.Add(LinkedApproveAfter)} +} + +func choiceOf(ticket, identity string, at time.Time) asks.Choice { + return asks.Choice{ID: "c-" + ticket + "-" + identity, Ticket: ticket, At: at, + Sender: asks.Sender{Identity: identity, Display: "Jo", Authenticated: true}} +} + +func ticketFor(m Message, label string) string { for _, a := range m.Actions { if a.Label == label { return a.Token } } - t.Fatalf("%q is not offered: %+v", label, m.Actions) return "" } -// **Only an acknowledgement is offered or performed.** Release, Stop, Start and Restart are refused by the -// allow-list: never offered, and a token for one (forged into the state) is refused without a call. -func TestOnlyAnAcknowledgementIsAnAnswer(t *testing.T) { - release := Action{Label: "Release", Verb: "mesh-delivery.release", Arguments: map[string]string{"id": "x", "why": ""}} - restart := Action{Label: "Restart", Verb: "node-service-manager.restart", Machine: "g14", - Arguments: map[string]string{"unit": "x.service"}} - start := Action{Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": "p", "why": ""}} - sneaky := Action{Label: "Silence", Verb: "mesh-controller.conditions", Arguments: map[string]string{"silence": "k", "stop": "p"}} - silence := Action{Label: "Silence for a week", Verb: "mesh-controller.conditions", - Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": "", "cause": "operator-answer"}} - for _, a := range []Action{release, restart, start, sneaky} { - if answerable(a) { - t.Errorf("%s is answerable", a.Label) +func labelsOf(m Message) string { + var out []string + for _, a := range m.Actions { + out = append(out, a.Label) + } + return strings.Join(out, ", ") +} + +func TestAnAskIsOfferedOnEachKindAsFarAsItCanCarryIt(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + a := heldAsk(c, "d-1") + a.Urgent = true + submit(t, h, "mesh-delivery", a) + settle(h, c) + if len(tg.sends) != 1 || len(dt.sends) != 1 { + t.Fatalf("telegram %d, desk %d", len(tg.sends), len(dt.sends)) + } + if got := labelsOf(tg.sends[0]); got != "Release, Stop, Silence for a week" { + t.Errorf("telegram offers %q", got) + } + // The desk proves nothing about who clicked: acknowledgements only, and the words say where the rest go. + if got := labelsOf(dt.sends[0]); got != "Silence for a week" { + t.Errorf("the desk offers %q", got) + } + if !strings.Contains(dt.sends[0].Body, "Release or Stop: answer on Telegram, or from the mesh MCP server.") { + t.Errorf("the desk does not say where to answer: %q", dt.sends[0].Body) + } + if rec := store.asks["mesh-delivery.d-1"]; rec.State != StateOpen || len(rec.Tickets) != 4 { + t.Errorf("recorded %+v", rec) + } +} + +func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) { + h, tg, _, c, _, _ := newRouter(t) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + c.pass(BurstWindow) + h.Flush() + if len(tg.sends) == 0 { + t.Fatal("nothing was said") + } + m := tg.sends[0] + if got := labelsOf(m); got != "Silence for a week" || !strings.Contains(m.Body, "Release or Stop: from the mesh MCP server.") { + t.Errorf("telegram without a link offers %q and says %q", got, m.Body) + } +} + +func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) { + h, tg, dt, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + release := ticketFor(tg.sends[0], "Release") + out, err := h.Chosen("telegram", choiceOf(release, "42", c.now())) + if err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + got := w.of("mesh-delivery") + if len(got) != 1 || got[0].Outcome != asks.OutcomeChosen || got[0].Option != "release" || got[0].Channel != "telegram" || + got[0].Says() != "the operator, via telegram (user id verified), chose Release" || strings.Join(got[0].Proofs, ",") != "P1" { + t.Fatalf("the warrant: %+v", got) + } + if _, err := got[0].For("mesh-delivery", heldAsk(c, "d-1")); err != nil { + t.Errorf("the asker would not act on it: %v", err) + } + if rec := store.asks["mesh-delivery.d-1"]; rec.State != string(asks.OutcomeChosen) || !rec.Told || !rec.Tickets[release].Used { + t.Errorf("the record: %+v", rec) + } + // Every copy says how it ended, its buttons gone. + for _, ch := range []*fakeChannel{tg, dt} { + var edited Message + for _, m := range ch.edits { + edited = m + } + if len(edited.Actions) != 0 || !strings.Contains(edited.Body, "Release: chosen at") { + t.Errorf("%s still offers %q: %q", ch.name, labelsOf(edited), edited.Body) } } - if !answerable(silence) { - t.Error("silence is not answerable") + // One warrant: the same answer again, and another answer after it, are refused and said. + if out, _ := h.Chosen("telegram", choiceOf(release, "42", c.now())); out != "chosen" { + t.Errorf("a redelivery of the same answer was acted on again: %s", out) } - h, dt, _, asked, _ := answering(t, release, restart, start, silence) - m := dt.sends[0] - var labels []string - for _, a := range m.Actions { - labels = append(labels, a.Label) + again := choiceOf(release, "42", c.now()) + again.ID = "another" + if out, err := h.Chosen("telegram", again); err == nil || out != "refused" { + t.Errorf("a used ticket was taken: %s", out) } - if strings.Join(labels, ",") != "Silence for a week,Details" { - t.Fatalf("offered %v", labels) + stop := ticketFor(tg.sends[0], "Stop") + if out, err := h.Chosen("telegram", choiceOf(stop, "42", c.now())); err == nil || !strings.Contains(err.Error(), "already answered") { + t.Errorf("a second answer stood: %s %v", out, err) } - // A token for Release, put in the state by hand: refused, nothing called, and said on the desk. - h.mu.Lock() - r := h.open["machine.shanks.units"] - r.Tokens["forged"] = Grant{Index: 0, Issued: h.Now()} - h.mu.Unlock() - before := len(dt.sends) - if out, err := h.Chosen("desktop", "g14", "forged", "e1"); err == nil || out != "refused" || len(*asked) != 0 { - t.Fatalf("a release was performed from a click: %q %v %v", out, err, *asked) + if len(w.of("mesh-delivery")) != 1 { + t.Errorf("more than one warrant: %+v", w.of("mesh-delivery")) } - if len(dt.sends) != before+1 || dt.sends[len(dt.sends)-1].Title != "That answer was not taken" { - t.Errorf("the refusal was not said where the click was: %+v", dt.sends[len(dt.sends)-1]) + if len(w.of("mesh-controller")) != 0 { + t.Error("another asker heard the warrant") } } -// **At most four answers**, Details included. -func TestANotificationOffersAtMostFourAnswers(t *testing.T) { - var many []Action - for i := 0; i < 6; i++ { - many = append(many, Action{Label: fmt.Sprintf("Silence %d", i), Verb: "mesh-controller.conditions", - Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}}) - } - _, dt, _, _, _ := answering(t, many...) - if n := len(dt.sends[0].Actions); n != MostActions || dt.sends[0].Actions[n-1].Label != DetailsLabel { - t.Fatalf("offered %+v", dt.sends[0].Actions) +func TestEveryAnswerThatIsNotTheOperatorsIsRefusedAndSaid(t *testing.T) { + for name, tc := range map[string]struct { + kind, identity string + authenticated bool + linkedAgo time.Duration + label string + pass time.Duration + want string + }{ + "an account not linked": {"telegram", "666", true, 2 * time.Hour, "Release", 0, "not linked as the operator"}, + "a sender the service did not say": {"telegram", "42", false, 2 * time.Hour, "Release", 0, "did not authenticate"}, + "a link younger than an hour": {"telegram", "42", true, 59 * time.Minute, "Release", 0, "can approve from"}, + "an ask that expired": {"telegram", "42", true, 2 * time.Hour, "Release", 3 * time.Hour, "expired"}, + "a ticket shown on another kind": {"desktop", "g14", true, 2 * time.Hour, "Release", 0, "not offered here"}, + } { + t.Run(name, func(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-tc.linkedAgo)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + ticket := ticketFor(tg.sends[0], tc.label) + if ticket == "" { + t.Fatalf("no %s offered: %q", tc.label, labelsOf(tg.sends[0])) + } + c.pass(tc.pass) + ch := choiceOf(ticket, tc.identity, c.now()) + ch.Sender.Authenticated = tc.authenticated + before := len(tg.sends) + out, err := h.Chosen(tc.kind, ch) + if err == nil || out != "refused" || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("%s %v, want refused for %q", out, err, tc.want) + } + if len(w.of("mesh-delivery")) != 0 { + t.Error("a warrant was issued") + } + if tc.kind == "telegram" && (len(tg.sends) != before+1 || tg.sends[len(tg.sends)-1].Title != "That answer was not taken") { + t.Errorf("the refusal was not said on the channel: %+v", tg.sends[before:]) + } + }) } } -// **A token is used once, an event acted on once**: the same token twice is refused the second time; the -// same event delivered twice is acted on once and answers what came of it. -func TestATokenIsUsedOnceAndAnEventActedOnOnce(t *testing.T) { - h, dt, _, asked, _ := answering(t) - tok := tokenOf(t, dt.sends[0], "Silence for a week") - if out, err := h.Chosen("desktop", "g14", tok, "e1"); err != nil || out != "done" || len(*asked) != 1 { - t.Fatalf("%q %v %v", out, err, *asked) - } - if out, err := h.Chosen("desktop", "g14", tok, "e1"); err != nil || out != "done" || len(*asked) != 1 { - t.Fatalf("a redelivered event was acted on again: %q %v %v", out, err, *asked) - } - if out, err := h.Chosen("desktop", "g14", tok, "e2"); err == nil || out != "refused" || len(*asked) != 1 { - t.Fatalf("the same token was taken twice: %q %v %v", out, err, *asked) - } -} - -// **A token is bound to its machine and its condition's life**: from another machine, for a condition -// cleared or silenced, or past its day, it is refused. -func TestATokenIsRefusedElsewhereAfterItsConditionAndPastItsDay(t *testing.T) { - h, dt, c, asked, k := answering(t) - tok := tokenOf(t, dt.sends[0], "Silence for a week") - if out, _ := h.Chosen("desktop", "shanks", tok, "e1"); out != "refused" { - t.Errorf("an answer from a machine it was not shown on was taken: %q", out) - } - if out, _ := h.Chosen("desktop", "g14", "0123456789abcdef01234567", "e2"); out != "refused" { - t.Errorf("an unknown token was taken: %q", out) - } - c.pass(TokenFor + time.Minute) - if out, _ := h.Chosen("desktop", "g14", tok, "e3"); out != "refused" { - t.Errorf("a token past its day was taken: %q", out) - } - - h, dt, c, asked, k = answering(t) - tok = tokenOf(t, dt.sends[0], "Silence for a week") - k.SilencedTill = c.now().Add(time.Hour) - h.Condition(EventChanged, k) - if out, _ := h.Chosen("desktop", "g14", tok, "e4"); out != "refused" { - t.Errorf("an answer for a silenced condition was taken: %q", out) - } - - h, dt, c, asked, k = answering(t) - tok = tokenOf(t, dt.sends[0], "Silence for a week") - h.Condition(EventCleared, Condition{Key: k.Key}) - if out, _ := h.Chosen("desktop", "g14", tok, "e5"); out != "refused" || len(*asked) != 0 { - t.Errorf("an answer for a cleared condition was taken: %q %v", out, *asked) - } -} - -// **A refused verb is said where it was chosen**: the mesh's refusal edits the notification. -func TestAVerbTheMeshRefusesIsSaid(t *testing.T) { - h, dt, _, _, _ := answering(t) - h.Ask = func(string, any) (json.RawMessage, error) { - return json.RawMessage(`{"isError":true,"content":[{"text":"silence is refused: no such condition"}]}`), nil - } - tok := tokenOf(t, dt.sends[0], "Silence for a week") - out, err := h.Chosen("desktop", "g14", tok, "e1") - if err == nil || !strings.HasPrefix(out, "failed") { - t.Fatalf("%q %v", out, err) - } - if m := dt.edits["g14=1"]; !strings.HasPrefix(m.Title, "Could not silence for a week") || - !strings.Contains(m.Body, "no such condition") { - t.Errorf("the refusal was not said: %+v", m) - } -} - -// **A words-only change to "Needs you" after a quiet time is said**, through the event and through a -// reading of the state; and a quiet warning is said once after a day. -func TestAQuietWarningIsSaidWhenItsWordsNeedTheOperator(t *testing.T) { - quiet := examples[0] - needing := quiet - needing.needs = "check that mesh-delivery runs." - needing.explanation = "Needs you: check that mesh-delivery runs. It has waited." - - // Through the event. - h, tg, dt, c, _ := newHolder(t) - k, _ := DecodeCondition(EventRaised, quiet.event(c.now(), false)) - h.Condition(EventRaised, k) - runFor(h, c, 2*time.Hour) - if len(dt.sends)+len(tg.sends) != 0 { - t.Fatal("said while it needed nothing") - } - k, _ = DecodeCondition(EventChanged, needing.event(c.now(), false)) - h.Condition(EventChanged, k) +func TestALinkApprovesAfterAnHourAndAcknowledgesAtOnce(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now()) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) settle(h, c) - if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: check that mesh-delivery runs.") { - t.Fatalf("event: desktop %+v", dt.sends) + release, silence := ticketFor(tg.sends[0], "Release"), ticketFor(tg.sends[0], "Silence for a week") + c.pass(59*time.Minute - BurstWindow) + if out, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err == nil || out != "refused" { + t.Fatalf("approved at 59 minutes: %s", out) } + c.pass(time.Minute) + stand(h, c) + late := choiceOf(release, "42", c.now().Add(time.Second)) + late.ID = "at-sixty" + if out, err := h.Chosen("telegram", late); err != nil || out != "chosen" { + t.Fatalf("refused at 60 minutes: %s %v", out, err) + } + // And an acknowledgement is taken at once from a fresh link. + h2, tg2, _, c2, store2, w2 := newRouter(t) + link(h2, store2, c2, "42", c2.now()) + submit(t, h2, "mesh-delivery", heldAsk(c2, "d-2")) + settle(h2, c2) + silence = ticketFor(tg2.sends[0], "Silence for a week") + if out, err := h2.Chosen("telegram", choiceOf(silence, "42", c2.now())); err != nil || out != "chosen" { + t.Fatalf("an acknowledgement from a fresh link was refused: %s %v", out, err) + } + if got := w2.of("mesh-delivery"); len(got) != 1 || got[0].Level != asks.Acknowledge { + t.Errorf("%+v", got) + } + _ = w +} - // Through a reading of the state. - h, tg, dt, c, _ = newHolder(t) - k, _ = DecodeCondition(EventRaised, quiet.event(c.now(), false)) - h.Condition(EventRaised, k) - runFor(h, c, 2*time.Hour) - k, _ = DecodeCondition("conditions", needing.event(c.now(), false)) - k.At = time.Time{} - h.Sync([]Condition{k}, c.now()) +func TestTheDeskAcknowledgesAndNeverApproves(t *testing.T) { + h, _, dt, c, _, w := newRouter(t) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) settle(h, c) - if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: check that mesh-delivery runs.") { - t.Fatalf("sync: desktop %+v", dt.sends) + silence := ticketFor(dt.sends[0], "Silence for a week") + ch := choiceOf(silence, "g14", c.now()) + ch.Sender.Authenticated = false + if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) } + got := w.of("mesh-delivery") + if len(got) != 1 || got[0].By.Verified != "a desk click: whoever was at the operator's session on g14" || len(got[0].Proofs) != 0 { + t.Errorf("%+v", got) + } + // An approve ticket forged onto the desk is refused: the kind cannot carry it. + h2, tg2, _, c2, store2, _ := newRouter(t) + link(h2, store2, c2, "42", c2.now().Add(-2*time.Hour)) + submit(t, h2, "mesh-delivery", heldAsk(c2, "d-1")) + settle(h2, c2) + rec := store2.asks["mesh-delivery.d-1"] + release := ticketFor(tg2.sends[0], "Release") + tk := rec.Tickets[release] + tk.Kind = "desktop" + rec.Tickets[release] = tk + store2.asks["mesh-delivery.d-1"] = rec + h2.keepAsk("mesh-delivery.d-1", rec, store2.revs["mesh-delivery.d-1"]) + if out, err := h2.Chosen("desktop", choiceOf(release, "g14", c2.now())); err == nil || out != "refused" || + !strings.Contains(err.Error(), "can only acknowledge") { + t.Errorf("the desk approved: %s %v", out, err) + } +} - // After a day, once, even if it still needs nothing. - h, tg, dt, c, _ = newHolder(t) - c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC) - k, _ = DecodeCondition(EventRaised, quiet.event(c.now(), false)) - h.Condition(EventRaised, k) - for i := 0; i < 50; i++ { - c.pass(time.Hour) - h.Tick() - settle(h, c) +func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) { + h, _, _, c, store, w := newRouter(t) + bad := heldAsk(c, "d-1") + bad.Options[0].Level = asks.Destroy + submit(t, h, "mesh-delivery", bad) + carrying := heldAsk(c, "d-2") + carrying.Explanation = "Needs you: see /etc/passwd on the machine." + submit(t, h, "mesh-delivery", carrying) + for i := 3; i <= 6; i++ { + a := heldAsk(c, "d-"+string(rune('0'+i))) + a.About = "about-" + string(rune('0'+i)) + submit(t, h, "mesh-delivery", a) } - if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Still open after a day: ") || - !strings.HasPrefix(dt.sends[0].Body, "Nothing for you to do.") { - t.Fatalf("after a day: desktop %+v", dt.sends) + got := w.of("mesh-delivery") + var refused []string + for _, g := range got { + if g.Outcome == asks.OutcomeRefused { + refused = append(refused, g.Ask+": "+g.Words) + } + } + all := strings.Join(refused, "\n") + for _, want := range []string{"d-1: the ask is refused: an ask that destroys lasts at most", "d-2: its words carry", + "d-6: its asker already has 3 open asks"} { + if !strings.Contains(all, want) { + t.Errorf("not refused: %q in\n%s", want, all) + } + } + if _, kept := store.asks["mesh-delivery.d-1"]; kept { + t.Error("a refused ask was recorded") + } + // No channel can carry anything: refused, never queued in silence. + h2, _, _, c2, _, w2 := newRouter(t) + h2.Bench.Stand("telegram", Standing{Ready: false, Why: "no bot token", At: c2.now()}) + h2.Bench.Stand("desktop", Standing{Ready: false, Why: "no session", At: c2.now()}) + submit(t, h2, "mesh-delivery", heldAsk(c2, "d-1")) + if got := w2.of("mesh-delivery"); len(got) != 1 || !strings.Contains(got[0].Words, "no channel can carry any of its answers now") { + t.Errorf("%+v", got) + } +} + +func TestAnAskExpiresAndNeverDefaults(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + c.pass(2 * time.Hour) + stand(h, c) + h.Tick() + got := w.of("mesh-delivery") + if len(got) != 1 || got[0].Outcome != asks.OutcomeExpired || got[0].Option != "" || !strings.Contains(got[0].Words, "the delivery stays held") { + t.Fatalf("%+v", got) + } + var edited Message + for _, m := range tg.edits { + edited = m + } + if len(edited.Actions) != 0 || !strings.Contains(edited.Body, "No longer answerable") { + t.Errorf("the copy still offers answers: %+v", edited) + } +} + +func TestANewerAskAboutTheSameThingReplacesTheOlderAndACancelEndsIt(t *testing.T) { + h, _, _, c, store, w := newRouter(t) + a := heldAsk(c, "d-1") + a.About = "delivery.hq" + submit(t, h, "mesh-delivery", a) + b := heldAsk(c, "d-2") + b.About = "delivery.hq" + submit(t, h, "mesh-delivery", b) + if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) { + t.Errorf("the older ask was not replaced: %s", store.asks["mesh-delivery.d-1"].State) + } + if err := h.TakeAsk("cancel", "mesh-delivery", json.RawMessage(`{"id":"d-2"}`)); err != nil { + t.Fatal(err) + } + got := w.of("mesh-delivery") + if len(got) != 2 || got[0].Outcome != asks.OutcomeReplaced || got[1].Outcome != asks.OutcomeCancelled { + t.Errorf("%+v", got) + } + // Another asker cannot cancel it: its cancel names its own ask, under its own name. + submit(t, h, "mesh-delivery", heldAsk(c, "d-3")) + _ = h.TakeAsk("cancel", "mesh-controller", json.RawMessage(`{"id":"d-3"}`)) + if store.asks["mesh-delivery.d-3"].State != StateOpen { + t.Error("another asker's cancel ended it") + } +} + +func TestAnAskAboutAConditionIsAnsweredOnItsMessage(t *testing.T) { + h, tg, dt, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + k := cond("delivery.hq.held", Warning, "the delivery of hq is held") + k.Raised = c.now() + h.Condition(EventRaised, k) + settle(h, c) + a := heldAsk(c, "d-1") + a.About = "delivery.hq.held" + submit(t, h, "mesh-controller", a) + settle(h, c) + // Its first message was on the desk alone (a warning, the desk ready); the ask re-shows it with answers. + var shown Message + for _, m := range dt.edits { + shown = m + } + if got := labelsOf(shown); got != "Silence for a week, Details" { + t.Errorf("the desk's copy offers %q: %+v", got, shown) + } + if store.asks["mesh-controller.d-1"].Record != "delivery.hq.held" { + t.Errorf("the ask is shown on %q", store.asks["mesh-controller.d-1"].Record) + } + silence := ticketFor(shown, "Silence for a week") + if out, err := h.Chosen("desktop", choiceOf(silence, "g14", c.now())); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + if got := w.of("mesh-controller"); len(got) != 1 || got[0].About != "delivery.hq.held" { + t.Errorf("%+v", got) } _ = tg } -// **A notifier that started again is offered the open answers again**, with fresh tokens: the old ones -// are refused, the new ones taken. -func TestANotifierThatRestartedIsOfferedTheAnswersAgain(t *testing.T) { - h, dt, _, asked, _ := answering(t) - old := tokenOf(t, dt.sends[0], "Silence for a week") - if n := h.Reoffer("g14"); n != 1 { - t.Fatalf("offered again: %d", n) +func TestAnAskerNotToldIsToldAgain(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + pub := h.Publish + h.Publish = func(string, any, string) error { return errorf("the bus is away") } + if out, _ := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Stop"), "42", c.now())); out != "chosen" { + t.Fatal(out) } - again := dt.edits["g14=1"] - fresh := tokenOf(t, again, "Silence for a week") - if fresh == old || again.Title != dt.sends[0].Title { - t.Fatalf("not offered again: %+v", again) + if store.asks["mesh-delivery.d-1"].Told { + t.Fatal("told while the bus was away") } - if out, _ := h.Chosen("desktop", "g14", old, "e1"); out != "refused" { - t.Errorf("the lost answer's token still works: %q", out) - } - if out, _ := h.Chosen("desktop", "g14", fresh, "e2"); out != "done" || len(*asked) != 1 { - t.Errorf("the fresh token: %q %v", out, *asked) - } - if n := h.Reoffer("shanks"); n != 0 { - t.Errorf("offered on a machine it was never shown on: %d", n) + h.Publish = pub + c.pass(time.Minute) + stand(h, c) + h.Tick() + if got := w.of("mesh-delivery"); len(got) != 1 || got[0].Option != "stop" || !store.asks["mesh-delivery.d-1"].Told { + t.Errorf("not told again: %+v", got) } } -// **A silence answers only its own condition** (ADR 0258): an action silencing another key is not offered, -// and a token for one is refused. -func TestASilenceAnswersOnlyItsOwnCondition(t *testing.T) { - other := Action{Label: "Silence another", Verb: "mesh-controller.conditions", - Arguments: map[string]string{"silence": "machine.ace.silent", "for": "7d", "why": ""}} - own := Action{Label: "Silence for a week", Verb: "mesh-controller.conditions", - Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}} - h, dt, _, asked, _ := answering(t, other, own) - var labels []string - for _, a := range dt.sends[0].Actions { - labels = append(labels, a.Label) - } - if strings.Join(labels, ",") != "Silence for a week,Details" { - t.Fatalf("offered %v", labels) - } - h.mu.Lock() - h.open["machine.shanks.units"].Tokens["forged"] = Grant{Index: 0, Issued: h.Now()} - h.mu.Unlock() - if out, _ := h.Chosen("desktop", "g14", "forged", "e1"); out != "refused" || len(*asked) != 0 { - t.Fatalf("another condition was silenced: %q %v", out, *asked) +func TestAnAnswerReplayedLongAfterIsNotActedOn(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + old := choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now().Add(-time.Hour)) + if out, _ := h.Chosen("telegram", old); out != "too old" || len(w.of("mesh-delivery")) != 0 { + t.Errorf("%s", out) } } -// **A record kept by the build before ADR 0258 loads**: its tokens were token -> index; it is read with -// them dropped, never skipped (the old tokens die, as they must). -func TestARecordKeptByTheBuildBeforeLoads(t *testing.T) { - old := `{"key":"machine.shanks.units","kind":"units","subject":"machine shanks","severity":"warning",` + - `"summary":"s","headline":"3 failed services on shanks","origin":"condition","more":"m",` + - `"raised":"2026-10-08T12:38:00Z","sent":{"desktop":"g14=7"},"count":1,` + - `"actions":[{"label":"Release","verb":"mesh-delivery.release","arguments":{"id":"x","why":""}}],` + - `"tokens":{"0123456789abcdef01234567":0,"89abcdef0123456789abcdef":-1}}` - var r Record - if err := json.Unmarshal([]byte(old), &r); err != nil { - t.Fatalf("the old record does not load: %v", err) +func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + asked := asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}} + h.Link("telegram", asked) + if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Link a Telegram account as you") { + t.Fatalf("no code on the desk: %+v", dt.sends) } - if r.Key != "machine.shanks.units" || len(r.Tokens) != 0 || r.Sent["desktop"] != "g14=7" { - t.Fatalf("%+v", r) + code := strings.ReplaceAll(strings.TrimSuffix(strings.SplitAfter(dt.sends[0].Body, "before ")[1][7:14], "."), " ", "") + if last := tg.sends[len(tg.sends)-1]; last.Title != "Send the code shown on your desk" { + t.Errorf("telegram was told %q", last.Title) } - // Loaded into a holder, its old answer is refused and nothing is called. - h, _, _, _, _ := newHolder(t) - asked := 0 - h.Ask = func(string, any) (json.RawMessage, error) { asked++; return nil, nil } - st := &memStore{recs: map[string]Record{r.Key: r}} - h.Store = st - if err := h.Load(); err != nil { + if p := store.links["telegram"]; p.CodeHash == "" || strings.Contains(p.CodeHash, code) { + t.Errorf("the code is kept in clear: %+v", p) + } + // Another account sending the code is refused; a wrong code is refused; the right one links. + if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code}); a.Accepted { + t.Error("another account linked with the code") + } + if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: "000000x"}); a.Accepted { + t.Error("a wrong code linked") + } + a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}) + if !a.Accepted || !strings.Contains(a.Words, "approve from 13:00") { + t.Fatalf("the right code: %+v (code %q)", a, code) + } + id := store.ids[identityKey("telegram", "42")] + if id.ApproveFrom.Sub(id.Linked) != time.Hour { + t.Errorf("approves from %s, linked %s", id.ApproveFrom, id.Linked) + } + // Said on every channel. + for _, ch := range []*fakeChannel{tg, dt} { + if last := ch.sends[len(ch.sends)-1]; !strings.Contains(last.Title, "is now linked as you") { + t.Errorf("%s was not told: %q", ch.name, last.Title) + } + } + // A second account cannot link while one is. + h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "666", Authenticated: true}}) + if _, pending := store.links["telegram"]; pending { + t.Error("a second link was begun while one is linked") + } + // Unlinking is said everywhere, and the account's answers are refused from then. + if _, err := h.Unlink("telegram"); err != nil { t.Fatal(err) } - if out, _ := h.Chosen("desktop", "g14", "0123456789abcdef01234567", "e1"); out != "refused" || asked != 0 { - t.Fatalf("an old token was taken: %q %d", out, asked) + if len(store.ids) != 0 || !strings.Contains(tg.sends[len(tg.sends)-1].Title, "No Telegram account is linked") { + t.Errorf("not unlinked and said: %v %q", store.ids, tg.sends[len(tg.sends)-1].Title) + } +} + +func TestALinkCodeExpiresAndFiveWrongTriesEndIt(t *testing.T) { + h, _, _, c, store, _ := newRouter(t) + jo := asks.Sender{Identity: "42", Authenticated: true} + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: jo}) + for i := 0; i < LinkTries; i++ { + h.Prove("telegram", asks.Code{Sender: jo, Code: "nope"}) + } + if _, pending := store.links["telegram"]; pending { + t.Error("five wrong codes did not end the link") + } + h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: jo}) + c.pass(LinkCodeFor) + if a := h.Prove("telegram", asks.Code{Sender: jo, Code: "123456"}); a.Accepted || !strings.Contains(a.Words, "expired") { + t.Errorf("%+v", a) + } + // The desk cannot be linked: it proves nothing about who clicked. + h.Link("desktop", asks.LinkAsked{ID: "l3", At: c.now(), Sender: asks.Sender{Identity: "g14"}}) + if _, pending := store.links["desktop"]; pending { + t.Error("the desk began a link") } } diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go new file mode 100644 index 00000000..89b54d3c --- /dev/null +++ b/modules/messenger/cmd/messenger/asks.go @@ -0,0 +1,721 @@ +package main + +// Asks and warrants (novox/hq ADR 0259): the router holds every ask, judges each answer and issues a +// warrant; the asker performs the option with its own grants. The router performs nothing. +// +// - **An ask arrives on the seat's work queue under its asker's name** (`accept.ask.`), which the +// bus grants that asker alone; the name is read from the subject, never from the body. It is recorded +// in the bucket `asks` under `.` before the work is acknowledged, so a restart loses none. +// - **It is refused at once, in words, on its asker's `decided` subject** when its words break a rule, +// when no ready channel can carry any of its answers, or when its asker already has three open. An +// option no channel can carry is named in the words, with where it can still be given. +// - **Each option gets a one-time ticket per channel kind that can carry it**: a button carries only the +// ticket. Bound to the ask, the option and the kind; dead when the ask closes or expires. +// - **An answer is judged in order, refusing at the first failure and saying so on the channel**: the ticket +// is known, unused and for this kind; the ask is open and unexpired; the sender is the operator's +// linked account where the kind verifies its sender; the kind's capabilities, from the controller's +// record of its claim, meet the option's level; for approve, the account was linked an hour ago or more. +// Then the ask is closed by compare-and-set — a second answer loses — and the warrant published. +// - **A warrant not yet heard by its asker is published again** every minute until the bus takes it. + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// ErrChanged is a compare-and-set that lost: another writer was first. +var ErrChanged = errors.New("written by another writer first") + +// LinkedApproveAfter is how long a newly linked account waits before it can approve (decided by the +// operator on 2026-10-08, until an authenticator-app code exists). +const LinkedApproveAfter = time.Hour + +// StateOpen is an ask not yet ended; every other state is the asks.Outcome it ended with. +const StateOpen = "open" + +// Ticket is one option's one-time ticket on one channel kind. +type Ticket struct { + Option string `json:"option"` + Kind string `json:"kind"` + Used bool `json:"used,omitempty"` +} + +// AskRecord is one ask as the router keeps it, under `.` in the bucket `asks`. Its asker reads +// it there under its own name. +type AskRecord struct { + Asker string `json:"asker"` + Ask asks.Ask `json:"ask"` + State string `json:"state"` + Opened time.Time `json:"opened"` + Closed time.Time `json:"closed,omitempty"` + Tickets map[string]Ticket `json:"tickets,omitempty"` + Warrant *asks.Warrant `json:"warrant,omitempty"` + // Told: the warrant, or the end, was published to the asker. + Told bool `json:"told,omitempty"` + // Record is the message the ask is shown on: a condition's key, or its own. + Record string `json:"record,omitempty"` +} + +// Identity is one account linked as the operator on a channel kind. +type Identity struct { + Kind string `json:"kind"` + Identity string `json:"identity"` + Display string `json:"display,omitempty"` + Linked time.Time `json:"linked"` + ApproveFrom time.Time `json:"approve-from"` +} + +// AskStore is the router's own state for asks and the operator's identities (ADR 0201). +type AskStore interface { + GetAsk(key string) (*AskRecord, uint64, error) + CreateAsk(key string, r AskRecord) (uint64, error) + UpdateAsk(key string, r AskRecord, revision uint64) (uint64, error) + AskKeys() ([]string, error) + GetIdentity(key string) (*Identity, error) + PutIdentity(key string, id Identity) error + DeleteIdentity(key string) error + IdentityKeys() ([]string, error) + GetLink(kind string) (*PendingLink, error) + PutLink(kind string, l PendingLink) error + DeleteLink(kind string) error +} + +type askEntry struct { + rec AskRecord + rev uint64 +} + +func askKey(asker, id string) string { return asker + "." + id } + +// loadAsks reads back every ask, and indexes the tickets of the open ones. +func (h *Holder) loadAsks() error { + if h.Asks == nil { + return nil + } + keys, err := h.Asks.AskKeys() + if err != nil { + return err + } + for _, k := range keys { + rec, rev, err := h.Asks.GetAsk(k) + if err != nil { + return err + } + if rec == nil { + continue + } + h.keepAsk(k, *rec, rev) + } + return nil +} + +func (h *Holder) keepAsk(key string, rec AskRecord, rev uint64) { + h.mu.Lock() + defer h.mu.Unlock() + if h.asks == nil { + h.asks = map[string]*askEntry{} + h.tickets = map[string]string{} + } + h.asks[key] = &askEntry{rec: rec, rev: rev} + // Kept after the ask ends, so a late answer is told why it was not taken. + for t := range rec.Tickets { + h.tickets[t] = key + } +} + +func (h *Holder) askOf(key string) *askEntry { + h.mu.Lock() + defer h.mu.Unlock() + if h.asks == nil { + return nil + } + return h.asks[key] +} + +// openAsk is the ask a record shows, when it is still open. +func (h *Holder) openAsk(r *Record) *AskRecord { + if r == nil || r.Ask == "" { + return nil + } + e := h.askOf(r.Ask) + if e == nil || e.rec.State != StateOpen { + return nil + } + rec := e.rec + return &rec +} + +// carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for +// approve verifying their sender with an account of the operator's linked. +func (h *Holder) carriers(level asks.Level) []string { + if h.Bench == nil { + return nil + } + var out []string + for _, k := range h.Bench.Kinds() { + if h.Bench.Ready(k.Kind) != nil || !h.Bench.Promises(k.Kind, "choice") { + continue + } + switch level { + case asks.Acknowledge: + out = append(out, k.Kind) + case asks.Approve: + if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) { + out = append(out, k.Kind) + } + } + // Destroy needs a code, which no channel carries yet. + } + return out +} + +// TakeAsk takes one ask or cancel from the seat's queue. asker is the subject's last token, which the bus +// grants that module alone. An error is returned only when the ask could not be recorded, so the queue +// offers it again; a refusal is said to the asker and the work is taken. +func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { + h.work.Lock() + defer h.work.Unlock() + h.mu.Lock() + h.init() + h.mu.Unlock() + if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) { + h.Logf("[messenger] an ask from %q was taken and dropped: not a module's name", asker) + return nil + } + if verb == "cancel" { + var c struct { + ID string `json:"id"` + } + if json.Unmarshal(body, &c) != nil || !asks.UsableID(c.ID) { + h.Logf("[messenger] a cancel from %s names no ask", asker) + return nil + } + return h.closeAsk(askKey(asker, c.ID), asks.OutcomeCancelled, "its asker took it back", nil, "") + } + var a asks.Ask + if err := json.Unmarshal(body, &a); err != nil { + h.refuseAsk(asker, asks.Ask{ID: "unreadable"}, "it could not be read: "+err.Error()) + return nil + } + key := askKey(asker, a.ID) + if h.askOf(key) != nil { + return nil // a redelivery: already recorded + } + now := h.Now() + if err := a.Check(now); err != nil { + h.refuseAsk(asker, a, err.Error()) + return nil + } + words := []string{a.Headline, a.Explanation, a.OnExpiry} + for _, o := range a.Options { + words = append(words, o.Label, o.Does) + } + if refusal, ok := Check(strings.Join(words, "\n")); !ok { + h.refuseAsk(asker, a, "its words carry "+refusal.String()+", which may not leave the mesh: move it into a reference or say it in words") + return nil + } + open, replaced := 0, "" + h.mu.Lock() + for k, e := range h.asks { + if e.rec.Asker != asker || e.rec.State != StateOpen { + continue + } + if a.About != "" && e.rec.Ask.About == a.About { + replaced = k + continue + } + open++ + } + h.mu.Unlock() + if open >= asks.MostOpen { + h.refuseAsk(asker, a, fmt.Sprintf("its asker already has %d open asks", open)) + return nil + } + rec := AskRecord{Asker: asker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{}} + for _, o := range a.Options { + for _, kind := range h.carriers(o.Level) { + rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: kind} + } + } + if len(rec.Tickets) == 0 { + h.refuseAsk(asker, a, "no channel can carry any of its answers now: "+h.whyNot()) + return nil + } + if replaced != "" { + if err := h.closeAsk(replaced, asks.OutcomeReplaced, "a newer ask about the same thing replaced it", nil, ""); err != nil && !errors.Is(err, ErrChanged) { + return err + } + } + // Shown on the condition it is about, when that is open here; otherwise as a message of its own. + h.mu.Lock() + target := h.open["ask."+key] + if a.About != "" { + if r := h.open[a.About]; r != nil && r.Cleared.IsZero() { + target = r + } + } + h.mu.Unlock() + if target != nil { + rec.Record = target.Key + } else { + rec.Record = "ask." + key + } + if h.Asks != nil { + rev, err := h.Asks.CreateAsk(key, rec) + if errors.Is(err, ErrChanged) { + got, rev, err := h.Asks.GetAsk(key) + if err != nil || got == nil { + return fmt.Errorf("the ask %s is on record and cannot be read: %v", key, err) + } + h.keepAsk(key, *got, rev) + return nil + } + if err != nil { + return fmt.Errorf("the ask %s could not be recorded: %w", key, err) + } + h.keepAsk(key, rec, rev) + } else { + h.keepAsk(key, rec, 0) + } + h.Logf("[messenger] %s asks %q (%s), shown on %s", asker, a.Headline, a.ID, rec.Record) + if target != nil { + h.mu.Lock() + target.Ask = key + h.mu.Unlock() + if !target.silenced(now) { + h.offerAsk(target) + } + h.persist(target) + return nil + } + severity := Warning + if a.Urgent { + severity = Urgent + } + r := Record{Key: "ask." + key, Kind: "ask", Subject: asker, Severity: severity, Summary: a.Headline, + Headline: a.Headline, Explanation: a.Explanation, Origin: "ask", More: "operator-channel.asks", + Raised: now, Sent: map[string]string{}, Seen: now, Count: 1, Ask: key} + h.mu.Lock() + h.open[r.Key] = &r + h.mu.Unlock() + h.deliver(&r, "raised") + h.persist(&r) + return nil +} + +// askKinds are the kinds an open ask about a record is offered on. +func (h *Holder) askKinds(r *Record) map[string]bool { + rec := h.openAsk(r) + if rec == nil { + return nil + } + out := map[string]bool{} + for _, t := range rec.Tickets { + out[t.Kind] = true + } + return out +} + +// offerAsk shows a record's new ask: edited in place where the record was shown, a message of its own on a +// channel that carries the ask and has not shown the record. +func (h *Holder) offerAsk(r *Record) { + kinds := h.askKinds(r) + h.mu.Lock() + sent := map[string]string{} + for k, v := range r.Sent { + sent[k] = v + } + h.mu.Unlock() + now := h.Now() + for _, ch := range h.channels() { + id, shown := sent[ch.Name()] + switch { + case shown && id != "" && ch.CanEdit(): + err := ch.Edit(id, h.sayOn(r, "asked", ch.Name())) + h.noteChannel(ch.Name(), err) + if err == nil { + h.record(Sent{At: now, Channel: ch.Name(), Key: r.Key, What: "asked", Outcome: "edited"}) + continue + } + h.enqueue(ch.Name(), item{Key: r.Key, What: "asked", Severity: r.Severity, At: now}) + case kinds[ch.Name()] && ch.Ready() == nil: + h.enqueue(ch.Name(), item{Key: r.Key, What: "asked", Severity: r.Severity, At: now}) + } + } + h.flush(now, false) +} + +// refuseAsk says to its asker that an ask was not taken, and why. +func (h *Holder) refuseAsk(asker string, a asks.Ask, why string) { + h.Logf("[messenger] refused an ask from %s (%s): %s", asker, a.ID, why) + h.tell(asks.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: asks.OutcomeRefused, Words: why, At: h.Now()}) +} + +// tell publishes a warrant, or an ask's end, to its asker alone, deduplicated by the ask. +func (h *Holder) tell(w asks.Warrant) error { + if h.Publish == nil { + return errors.New("this router cannot reach the bus") + } + err := h.Publish(asks.DecidedSubject(w.Asker), w, askKey(w.Asker, w.Ask)+"."+string(w.Outcome)) + if err != nil { + h.Logf("[messenger] could not tell %s how its ask %s ended (%v); told again in a minute", w.Asker, w.Ask, err) + } + return err +} + +// closeAsk ends an open ask by compare-and-set, tells its asker, and edits every copy. A second close +// loses: ErrChanged when another outcome was first. +func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *asks.Warrant, used string) error { + e := h.askOf(key) + if e == nil { + return nil + } + rec, rev := e.rec, e.rev + if h.Asks != nil { + got, r, err := h.Asks.GetAsk(key) + if err != nil { + return err + } + if got != nil { + rec, rev = *got, r + } + } + if rec.State != StateOpen { + h.keepAsk(key, rec, rev) + return ErrChanged + } + now := h.Now() + rec.State = string(outcome) + rec.Closed = now + if t, ok := rec.Tickets[used]; ok { + t.Used = true + rec.Tickets[used] = t + } + if w == nil { + w = &asks.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: outcome, Words: words, At: now} + } + rec.Warrant = w + if h.Asks != nil { + newRev, err := h.Asks.UpdateAsk(key, rec, rev) + if err != nil { + if errors.Is(err, ErrChanged) { + if got, r, gerr := h.Asks.GetAsk(key); gerr == nil && got != nil { + h.keepAsk(key, *got, r) + } + } + return err + } + rev = newRev + } + h.keepAsk(key, rec, rev) + if h.tell(*w) == nil { + h.markTold(key) + } + h.showClosed(rec) + return nil +} + +// markTold records that the asker was told. +func (h *Holder) markTold(key string) { + e := h.askOf(key) + if e == nil { + return + } + rec, rev := e.rec, e.rev + rec.Told = true + if h.Asks != nil { + newRev, err := h.Asks.UpdateAsk(key, rec, rev) + if err != nil { + return // read again and told again by the sweep + } + rev = newRev + } + h.keepAsk(key, rec, rev) +} + +// showClosed edits every copy of an ask's message to say how it ended, its buttons gone. +func (h *Holder) showClosed(rec AskRecord) { + h.mu.Lock() + r := h.open[rec.Record] + h.mu.Unlock() + if r == nil { + return + } + h.mu.Lock() + switch { + case rec.Warrant != nil && rec.Warrant.Outcome == asks.OutcomeChosen: + r.Answered = rec.Warrant.Label + ": chosen at " + rec.Closed.In(h.zone()).Format("15:04") + " on " + Display(rec.Warrant.By.Kind) + case rec.State == string(asks.OutcomeExpired): + r.Answered = "No longer answerable: nobody answered in time. " + capital(rec.Ask.OnExpiry) + case rec.State == string(asks.OutcomeReplaced): + r.Answered = "" + default: + r.Answered = "No longer needed." + } + ownMessage := r.Origin == "ask" + h.mu.Unlock() + if r.told() { + h.edit(r, "answered", false) + } + if ownMessage && rec.State != string(asks.OutcomeReplaced) { + h.mu.Lock() + r.Cleared = h.Now() + h.mu.Unlock() + } + h.persist(r) +} + +// sweepAsks ends asks past their expiry, and tells again an asker not yet told. Called every minute. +func (h *Holder) sweepAsks(now time.Time) { + h.mu.Lock() + var expired, untold []string + for k, e := range h.asks { + switch { + case e.rec.State == StateOpen && !now.Before(e.rec.Ask.Expires): + expired = append(expired, k) + case e.rec.State != StateOpen && !e.rec.Told && e.rec.Warrant != nil: + untold = append(untold, k) + } + } + h.mu.Unlock() + sort.Strings(expired) + for _, k := range expired { + e := h.askOf(k) + if err := h.closeAsk(k, asks.OutcomeExpired, "nobody answered in time: "+e.rec.Ask.OnExpiry, nil, ""); err != nil && !errors.Is(err, ErrChanged) { + h.Logf("[messenger] could not end the expired ask %s: %v", k, err) + } + } + for _, k := range untold { + e := h.askOf(k) + if e != nil && h.tell(*e.rec.Warrant) == nil { + h.markTold(k) + } + } +} + +// offers are an open ask's buttons on one kind: each option offered there, by its ticket. +func (h *Holder) offers(rec *AskRecord, kind string) []Choice { + var out []Choice + for _, o := range rec.Ask.Options { + for t, tk := range rec.Tickets { + if tk.Kind == kind && tk.Option == o.ID && !tk.Used { + out = append(out, Choice{Token: t, Label: o.Label}) + } + } + } + return out +} + +// elsewhere is the line an ask's message carries on a kind for the options it cannot carry there: where +// else each can be given — "Release or Stop: answer on Telegram, or from the mesh MCP server." +func (h *Holder) elsewhere(rec *AskRecord, kind string) string { + var labels []string + where := map[string]bool{} + for _, o := range rec.Ask.Options { + here := false + for _, tk := range rec.Tickets { + if tk.Option == o.ID && tk.Kind == kind { + here = true + } + } + if here { + continue + } + labels = append(labels, o.Label) + for _, tk := range rec.Tickets { + if tk.Option == o.ID && tk.Kind != kind { + where[tk.Kind] = true + } + } + } + if len(labels) == 0 { + return "" + } + var kinds []string + for k := range where { + kinds = append(kinds, Display(k)) + } + sort.Strings(kinds) + answer := "from the mesh MCP server" + if len(kinds) > 0 { + answer = "answer on " + strings.Join(kinds, " or ") + ", or from the mesh MCP server" + } + return joinOr(labels) + ": " + answer + "." +} + +func joinOr(labels []string) string { + if len(labels) <= 1 { + return strings.Join(labels, "") + } + return strings.Join(labels[:len(labels)-1], ", ") + " or " + labels[len(labels)-1] +} + +// judge is an answer to an ask's option, chosen on a kind: refused at the first failure, in words said on +// the kind; otherwise the ask closed and the warrant published (ADR 0259 §5). +func (h *Holder) judge(kind string, c asks.Choice, key string) (string, error) { + e := h.askOf(key) + if e == nil { + return h.refuseChoice(kind, c, "it belongs to no ask this router holds") + } + rec := e.rec + if h.Asks != nil { + if got, _, err := h.Asks.GetAsk(key); err == nil && got != nil { + rec = *got + } + } + now := h.Now() + t, issued := rec.Tickets[c.Ticket] + switch { + case !issued: + return h.refuseChoice(kind, c, "it belongs to no ask this router holds") + case t.Kind != kind: + return h.refuseChoice(kind, c, "it was not offered here") + case rec.State != StateOpen: + return h.refuseChoice(kind, c, "the question was already answered or has ended") + case t.Used: + return h.refuseChoice(kind, c, "it was already chosen once") + case !now.Before(rec.Ask.Expires): + return h.refuseChoice(kind, c, "the question expired at "+rec.Ask.Expires.In(h.zone()).Format("15:04")) + } + o, offered := rec.Ask.Option(t.Option) + if !offered { + return h.refuseChoice(kind, c, "it names an answer the question does not offer") + } + info, held := h.Bench.Kind(kind) + if !held || !h.Bench.Promises(kind, "choice") { + return h.refuseChoice(kind, c, "this channel is not one the mesh holds for answers") + } + person := asks.Person{Who: asks.Operator, Kind: kind, Identity: c.Sender.Identity, Display: c.Sender.Display} + verifies := h.Bench.Promises(kind, "verified-sender") + var id *Identity + if verifies { + if !c.Sender.Authenticated { + return h.refuseChoice(kind, c, "the service did not authenticate who sent it") + } + id = h.identity(kind, c.Sender.Identity) + if id == nil { + return h.refuseChoice(kind, c, "this account is not linked as the operator") + } + person.Verified = "user id verified" + } else { + person.Verified = "a desk click: whoever was at the operator's session on " + c.Sender.Identity + } + proofs := []string{} + switch o.Level { + case asks.Acknowledge: + case asks.Approve: + if !verifies { + return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it can only acknowledge") + } + if now.Before(id.ApproveFrom) { + return h.refuseChoice(kind, c, "this account was linked at "+id.Linked.In(h.zone()).Format("15:04")+ + " and can approve from "+id.ApproveFrom.In(h.zone()).Format("15:04")) + } + proofs = append(proofs, "P1") + default: + return h.refuseChoice(kind, c, "it needs a code from the authenticator app, which the mesh does not take yet") + } + w := &asks.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: asks.OutcomeChosen, + Option: o.ID, Label: o.Label, Level: o.Level, By: &person, Channel: info.Module, Proofs: proofs, At: now} + if err := h.closeAsk(key, asks.OutcomeChosen, "", w, c.Ticket); err != nil { + if errors.Is(err, ErrChanged) { + return h.refuseChoice(kind, c, "the question was already answered") + } + return h.refuseChoice(kind, c, "it could not be recorded: "+err.Error()) + } + h.record(Sent{At: now, Channel: kind, Key: rec.Record, What: "answer " + o.Label, Outcome: "warrant to " + rec.Asker}) + h.Logf("[messenger] %s: a warrant for %s's ask %s", w.Says(), rec.Asker, rec.Ask.ID) + return "chosen", nil +} + +// refuseChoice says on the kind that an answer was not taken, and why. +func (h *Holder) refuseChoice(kind string, c asks.Choice, why string) (string, error) { + h.Logf("[messenger] an answer on %s from %s was refused: %s", kind, c.Sender.Identity, why) + h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "answer", Outcome: "refused: " + why}) + h.showOn(kind, Message{Title: "That answer was not taken", Body: capital(why) + ". Nothing was done.", + Quiet: true, Reply: c.ID}) + return "refused", errorf("%s", why) +} + +// showOn says a message of the router's own on one kind, at once: an answer's refusal, details, a link's code. +func (h *Holder) showOn(kind string, m Message) error { + for _, ch := range h.channels() { + if ch.Name() == kind { + _, err := ch.Send(m) + h.noteChannel(kind, err) + return err + } + } + if h.Publish == nil { + return errors.New("this router cannot reach the bus") + } + out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply} + if h.Bench != nil && h.Bench.To != nil { + out.To = h.Bench.To(kind) + } + err := h.Publish(asks.ChannelSubject(asks.Send, kind), out, out.Handle) + h.noteChannel(kind, err) + return err +} + +func ticket() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// AskView is one ask as the tool `asks` answers it. +type AskView struct { + Key string `json:"key"` + Asker string `json:"asker"` + Headline string `json:"headline"` + State string `json:"state"` + Opened string `json:"opened"` + Expires string `json:"expires"` + Offered []string `json:"offered_on,omitempty"` + Answer string `json:"answer,omitempty"` + Told bool `json:"asker_told"` +} + +// AsksNow is every ask, open first, newest first. +func (h *Holder) AsksNow() []AskView { + h.mu.Lock() + defer h.mu.Unlock() + var out []AskView + for k, e := range h.asks { + v := AskView{Key: k, Asker: e.rec.Asker, Headline: e.rec.Ask.Headline, State: e.rec.State, + Opened: e.rec.Opened.UTC().Format(time.RFC3339), Expires: e.rec.Ask.Expires.UTC().Format(time.RFC3339), + Told: e.rec.Told} + kinds := map[string]bool{} + for _, t := range e.rec.Tickets { + kinds[t.Kind] = true + } + for k := range kinds { + v.Offered = append(v.Offered, k) + } + sort.Strings(v.Offered) + if w := e.rec.Warrant; w != nil { + if w.Outcome == asks.OutcomeChosen { + v.Answer = w.Says() + } else { + v.Answer = string(w.Outcome) + ": " + w.Words + } + } + out = append(out, v) + } + sort.Slice(out, func(i, j int) bool { + if (out[i].State == StateOpen) != (out[j].State == StateOpen) { + return out[i].State == StateOpen + } + return out[i].Opened > out[j].Opened + }) + return out +} diff --git a/modules/messenger/cmd/messenger/channels_test.go b/modules/messenger/cmd/messenger/channels_test.go deleted file mode 100644 index 1cc06c14..00000000 --- a/modules/messenger/cmd/messenger/channels_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -const goodToken = "123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsawQ" - -func tokenFile(t *testing.T, content string) string { - t.Helper() - p := filepath.Join(t.TempDir(), "telegram-token") - if content != "" { - if err := os.WriteFile(p, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - } - return p -} - -func TestTelegramSaysWhatItLacks(t *testing.T) { - for _, c := range []struct{ token, chat, says string }{ - {"", "42", "not on this machine yet"}, - {"r4nd0mlyMadeByTheMeshBecauseNobodyAcceptedOne", "42", "not a bot token"}, - {goodToken, "", "telegram-chat-id is not given"}, - {goodToken, "not a chat", "is not a chat id"}, - } { - tg := NewTelegram(TelegramConfig{TokenFile: tokenFile(t, c.token), ChatID: func() string { return c.chat }}) - err := tg.Ready() - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%+v: %v", c, err) - } - if err != nil && strings.Contains(err.Error(), goodToken) { - t.Errorf("the token is in the words") - } - } -} - -func TestTelegramSendsEditsAndNeverSaysItsToken(t *testing.T) { - var asked []string - var bodies []map[string]any - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - asked = append(asked, r.URL.Path) - raw, _ := io.ReadAll(r.Body) - var b map[string]any - _ = json.Unmarshal(raw, &b) - bodies = append(bodies, b) - switch { - case strings.HasSuffix(r.URL.Path, "/sendMessage"): - _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":77}}`)) - case strings.HasSuffix(r.URL.Path, "/editMessageText"): - _, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: message to edit not found"}`)) - default: - _, _ = w.Write([]byte(`{"ok":true,"result":{"username":"mesh_bot"}}`)) - } - })) - defer srv.Close() - old := TelegramAPI - TelegramAPI = srv.URL - defer func() { TelegramAPI = old }() - tg := NewTelegram(TelegramConfig{TokenFile: tokenFile(t, goodToken+"\n"), ChatID: func() string { return "-1001" }}) - id, err := tg.Send(Message{Title: "URGENT: x", Body: "key: a.b.c"}) - if err != nil || id != "77" { - t.Fatalf("%q %v", id, err) - } - if asked[0] != "/bot"+goodToken+"/sendMessage" || bodies[0]["chat_id"] != "-1001" || bodies[0]["text"] != "URGENT: x\nkey: a.b.c" { - t.Fatalf("%v %v", asked, bodies[0]) - } - err = tg.Edit("77", Message{Title: "CLEARED"}) - if err == nil || !strings.Contains(err.Error(), "message to edit not found") || strings.Contains(err.Error(), goodToken) { - t.Fatalf("edit: %v", err) - } - if bodies[1]["message_id"] != float64(77) { - t.Fatalf("message id: %v", bodies[1]["message_id"]) - } - if who, err := tg.Who(); err != nil || who != "mesh_bot" { - t.Fatalf("%q %v", who, err) - } - // Nothing answers: the error is in words, without the URL that carries the token. - srv.Close() - _, err = tg.Send(Message{Title: "x"}) - if err == nil || strings.Contains(err.Error(), goodToken) || strings.Contains(err.Error(), "/bot") { - t.Fatalf("unreachable: %v", err) - } -} - -func TestTheDesktopAsksTheNotifierSeatOnEachMachine(t *testing.T) { - var asked []string - d := &Desktop{ - Machines: func() []string { return []string{"one", "two"} }, - Ask: func(key string, body any) (json.RawMessage, error) { - asked = append(asked, key) - args := body.(map[string]any) - if args["body"] != "a <b>" || args["urgency"] != "critical" { - t.Errorf("args: %v", args) - } - if strings.HasSuffix(key, "@two") { - return nil, errors.New("the account is not logged in") - } - return json.RawMessage(`{"id":12}`), nil - }, - } - id, err := d.Send(Message{Title: "t", Body: "a ", Urgent: true}) - if err != nil || id != "one=12" { - t.Fatalf("%q %v", id, err) - } - if asked[0] != "seat:node-notifier.send@one" || asked[1] != "seat:node-notifier.send@two" { - t.Fatalf("%v", asked) - } - if d.LastAnswers()["two"] == "" || d.LastAnswers()["one"] != "" { - t.Fatalf("%v", d.LastAnswers()) - } - asked = nil - if err := d.Edit("one=12", Message{Title: "t", Body: "a ", Urgent: true}); err != nil || len(asked) != 1 { - t.Fatalf("edit asked %v: %v", asked, err) - } - none := &Desktop{Machines: func() []string { return nil }} - if err := none.Ready(); err == nil || !strings.Contains(err.Error(), "desktop-machines") { - t.Fatalf("%v", err) - } -} - -func TestTheNotifiersAnswerIsReadInEitherShape(t *testing.T) { - for raw, want := range map[string]int{ - `{"id":5}`: 5, - `{"content":[{"type":"text","text":"{\"id\":6}"}]}`: 6, - `"{\"id\":7}"`: 7, - } { - if got, err := notificationID(json.RawMessage(raw)); err != nil || got != want { - t.Errorf("%s: %d %v", raw, got, err) - } - } - if _, err := notificationID(json.RawMessage(`{"content":[{"text":"no session"}],"isError":true}`)); err == nil { - t.Errorf("an error answer read as an id") - } -} - -func TestSettingsAreReadAsTheMeshMergesThem(t *testing.T) { - p := filepath.Join(t.TempDir(), "settings.json") - _ = os.WriteFile(p, []byte(`{"telegram-chat-id": 123456, "desktop-machines": ["a", " ", "b"]}`), 0o600) - s, err := readSettings(p) - if err != nil || s.TelegramChatID != "123456" || len(s.DesktopMachines) != 2 { - t.Fatalf("%+v %v", s, err) - } -} diff --git a/modules/messenger/cmd/messenger/desktop.go b/modules/messenger/cmd/messenger/desktop.go deleted file mode 100644 index 735ef35b..00000000 --- a/modules/messenger/cmd/messenger/desktop.go +++ /dev/null @@ -1,186 +0,0 @@ -package main - -// The desktop notifier (novox/hq to-be 45 §5, ADR 0208): the `node-notifier` seat's `send` verb on -// the machine the operator is at, asked through the mesh. Nothing new runs on that machine — the -// seat's holder (dunst) already answers `send` in the operator's session, and answers with an error -// when no session is there. That error is what "the operator's session is there" is read from until -// presence is decided (research 028 Q4): a machine whose notifier answers has a session. -// -// Which machines to try is the setting desktop-machines, in order. None given: the channel is not -// configured, the status says so, and a warning goes to Telegram. - -import ( - "encoding/json" - "errors" - "fmt" - "html" - "strings" - "sync" -) - -// Asker calls a tool through the mesh, as stdio.Ask does. -type Asker func(key string, body any) (json.RawMessage, error) - -// Desktop sends to the notifier of each configured machine. -type Desktop struct { - Machines func() []string - Ask Asker - - mu sync.Mutex - last map[string]string // machine -> the last error, or "" after a success -} - -func (d *Desktop) Name() string { return "desktop" } -func (d *Desktop) CanEdit() bool { return true } - -// SilentEdit: replacing a notification shows it again. -func (d *Desktop) SilentEdit() bool { return false } - -func (d *Desktop) Ready() error { - if len(d.Machines()) == 0 { - return errors.New("no machine to show it on: the setting desktop-machines is not given " + - "(`settings` for messenger with {\"desktop-machines\": [\"\"]})") - } - return nil -} - -// Send shows the message on every configured machine that has a session, and answers -// `=` for each one that took it. It fails only when none did. -func (d *Desktop) Send(m Message) (string, error) { - return d.show(m, nil) -} - -// Edit replaces the notification shown before on each machine that showed it. -func (d *Desktop) Edit(id string, m Message) error { - shown := map[string]int{} - for _, part := range strings.Split(id, ",") { - machine, n, ok := strings.Cut(part, "=") - var i int - if ok { - if _, err := fmt.Sscan(n, &i); err == nil { - shown[machine] = i - } - } - } - if len(shown) == 0 { - return errors.New("no notification on record to replace") - } - _, err := d.show(m, shown) - return err -} - -func (d *Desktop) show(m Message, replace map[string]int) (string, error) { - if err := d.Ready(); err != nil { - return "", err - } - urgency := "normal" - switch { - case m.Quiet: - urgency = "low" - case m.Urgent: - urgency = "critical" - } - var took []string - var failed []string - for _, machine := range d.Machines() { - if replace != nil { - if _, shown := replace[machine]; !shown { - continue - } - } - args := map[string]any{ - "summary": m.Title, - "body": html.EscapeString(m.Body), - "urgency": urgency, - "app_name": "mesh", - } - if replace != nil { - args["replace_id"] = replace[machine] - } - if len(m.Actions) > 0 { - // Answers stay until the operator dismisses them: a warning's answers must not die in ten seconds. - args["expire_ms"] = 0 - // The answers the notification offers (node-notifier's actions): the notifier hands the chosen - // token back as an event, which the holder takes (answers.go). - args["actions"] = m.Actions - } - raw, err := d.Ask("seat:node-notifier.send@"+machine, args) - if err == nil { - var id int - id, err = notificationID(raw) - if err == nil { - took = append(took, fmt.Sprintf("%s=%d", machine, id)) - } - } - d.note(machine, err) - if err != nil { - failed = append(failed, machine+": "+err.Error()) - } - } - if len(took) == 0 { - return "", errors.New("no machine showed it — " + strings.Join(failed, "; ")) - } - return strings.Join(took, ","), nil -} - -func (d *Desktop) note(machine string, err error) { - d.mu.Lock() - defer d.mu.Unlock() - if d.last == nil { - d.last = map[string]string{} - } - if err != nil { - d.last[machine] = err.Error() - } else { - d.last[machine] = "" - } -} - -// Machines' last answers, for the status: "" is a machine that took the last message shown to it. -func (d *Desktop) LastAnswers() map[string]string { - d.mu.Lock() - defer d.mu.Unlock() - out := map[string]string{} - for k, v := range d.last { - out[k] = v - } - return out -} - -// showOne shows a message on one machine only: an answer's outcome, or details, where it was asked. -func (d *Desktop) showOne(machine string, m Message) (string, error) { - only := &Desktop{Machines: func() []string { return []string{machine} }, Ask: d.Ask} - id, err := only.Send(m) - d.note(machine, err) - return id, err -} - -// notificationID reads the notifier's answer, {"id": N}, whether the runtime hands it over bare or in -// the tool reply's text content. -func notificationID(raw json.RawMessage) (int, error) { - var direct struct { - ID *int `json:"id"` - } - if json.Unmarshal(raw, &direct) == nil && direct.ID != nil { - return *direct.ID, nil - } - var wrapped struct { - Content []struct { - Text string `json:"text"` - } `json:"content"` - IsError bool `json:"isError"` - } - if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Content) > 0 { - if wrapped.IsError { - return 0, errors.New(wrapped.Content[0].Text) - } - if json.Unmarshal([]byte(wrapped.Content[0].Text), &direct) == nil && direct.ID != nil { - return *direct.ID, nil - } - } - var s string - if json.Unmarshal(raw, &s) == nil && json.Unmarshal([]byte(s), &direct) == nil && direct.ID != nil { - return *direct.ID, nil - } - return 0, fmt.Errorf("the notifier answered no id: %.80s", string(raw)) -} diff --git a/modules/messenger/cmd/messenger/history_test.go b/modules/messenger/cmd/messenger/history_test.go index aed732d3..d8571174 100644 --- a/modules/messenger/cmd/messenger/history_test.go +++ b/modules/messenger/cmd/messenger/history_test.go @@ -270,7 +270,7 @@ func TestAClearingOfSomethingNeverSaidSaysNothing(t *testing.T) { func TestABurstIsOneMessage(t *testing.T) { h, tg, _, c, _ := newHolder(t) - h.Desktop = nil + h.Desk = nil for i := 0; i < 5; i++ { h.Condition(EventRaised, cond(fmt.Sprintf("seat.s%d.silent", i), Warning, fmt.Sprintf("seat %d is silent", i))) c.pass(2 * time.Second) @@ -413,7 +413,7 @@ func TestARestartSaysNothingAgain(t *testing.T) { w.At = c.now() h.Condition(EventRaised, w) // held when the holder stops c.pass(5 * time.Second) - again := &Holder{Telegram: tg, Desktop: dt, Store: st, Now: c.now, Logf: t.Logf} + again := &Holder{Away: tg, Desk: dt, Store: st, Now: c.now, Logf: t.Logf} if err := again.Load(); err != nil { t.Fatal(err) } diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 7361d1aa..0fbd5803 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -89,6 +89,8 @@ type Message struct { Urgent bool Quiet bool // a clearing: shown without urgency Actions []Choice + // Reply is the answer this message answers, by its id: the channel shows it where that was given. + Reply string } // Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label. @@ -145,7 +147,10 @@ type Record struct { // nothing stays silent for ever. QuietReminded bool `json:"quiet_reminded,omitempty"` // Answered is what the operator chose from a notification, and what came of it. - Answered string `json:"answered,omitempty"` + Answered string `json:"answered,omitempty"` + // Ask is the open ask about this record, `.`: its options are this record's answers + // (novox/hq ADR 0259). + Ask string `json:"ask,omitempty"` Origin string `json:"origin"` More string `json:"more"` Raised time.Time `json:"raised"` @@ -212,13 +217,20 @@ type Store interface { Recent() ([]Sent, error) } -// Holder is the seat's holder. +// Holder is the seat's holder: the router (novox/hq ADR 0234 §5, ADR 0259). It keeps no channel's client: +// Away is the channel that reaches the operator away from the machines (a kind promising reaches-away), +// Desk the one at the machines; each is a kind of the `channel` bench, held by a module of its own. type Holder struct { - Telegram Channel - Desktop Channel - Store Store - Now func() time.Time - Logf func(string, ...any) + Away Channel + Desk Channel + Store Store + // Asks keeps the asks, the operator's identities and their links (asks.go, link.go); Bench is what + // the controller says of every channel kind and what each says of itself; Publish says on a seat. + Asks AskStore + Bench *Bench + Publish func(subject string, body any, id string) error + Now func() time.Time + Logf func(string, ...any) // Emit states a fact as this module (refused); nil states nothing. Emit func(event string, body any) error // Ask calls a seat verb through the mesh, as this module: the answers the operator chooses. @@ -255,6 +267,9 @@ type Holder struct { stale int // answered is what came of each answer event, by its id: a redelivered one is acted on once. answered map[string]string + // asks are the asks this router holds, by `.`, and tickets each ask's tickets to it. + asks map[string]*askEntry + tickets map[string]string } func (h *Holder) init() { @@ -308,7 +323,10 @@ func (h *Holder) Load() error { if sent, err := h.Store.Recent(); err == nil { h.recent = sent } - return nil + h.mu.Unlock() + err = h.loadAsks() + h.mu.Lock() + return err } // Start marks the holder's start: the grace minute counts from here. @@ -532,6 +550,7 @@ func (h *Holder) Tick() { } h.mu.Unlock() sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) }) + h.sweepAsks(now) for _, r := range recs { switch { case !r.Cleared.IsZero(): @@ -592,7 +611,35 @@ func remindAfter(severity string) time.Duration { // say composes a record's message under the content rule: refused, it is composed again with less of // it, until what remains may leave. The refusal is recorded and stated once per record. -func (h *Holder) say(r *Record, what string) Message { +func (h *Holder) say(r *Record, what string) Message { return h.sayOn(r, what, "") } + +// sayOn is a record's message on one channel kind: its words, what came of an answer, the answers that kind +// can carry, and where the others can be given. An empty kind is the words alone. +func (h *Holder) sayOn(r *Record, what, kind string) Message { + m := h.words(r, what) + if what == "cleared" || m.Title == "A mesh message was withheld" { + return m + } + if r.Answered != "" { + m.Body = strings.TrimSpace(r.Answered + "\n" + m.Body) + } + if kind == "" { + return m + } + if rec := h.openAsk(r); rec != nil { + if line := h.elsewhere(rec, kind); line != "" { + if _, ok := Check(line); ok { + m.Body = strings.TrimSpace(m.Body + "\n" + line) + } + } + } + m.Actions = h.choices(r, kind) + return m +} + +// words is a record's message under the content rule: refused, it is composed again with less of it, +// until what remains may leave. The refusal is recorded and stated once per record. +func (h *Holder) words(r *Record, what string) Message { now := h.Now() if r.Refused != "" { // Refused before: its words stay withheld in every later message too. @@ -606,9 +653,6 @@ func (h *Holder) say(r *Record, what string) Message { m := compose(r, what, now, h.zone(), 0) refusal, ok := Check(m.Text()) if ok { - if what != "cleared" { - m.Actions = h.choices(r) - } return m } h.mu.Lock() @@ -643,7 +687,7 @@ func (h *Holder) say(r *Record, what string) Message { func (h *Holder) channels() []Channel { var out []Channel - for _, c := range []Channel{h.Telegram, h.Desktop} { + for _, c := range []Channel{h.Away, h.Desk} { if c != nil { out = append(out, c) } diff --git a/modules/messenger/cmd/messenger/holder_test.go b/modules/messenger/cmd/messenger/holder_test.go index df48e702..f96281fd 100644 --- a/modules/messenger/cmd/messenger/holder_test.go +++ b/modules/messenger/cmd/messenger/holder_test.go @@ -108,7 +108,7 @@ func newHolder(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memS tg, dt := &fakeChannel{name: "telegram", silent: true}, &fakeChannel{name: "desktop"} st := &memStore{} var emitted []string - h := &Holder{Telegram: tg, Desktop: dt, Store: st, Now: c.now, Logf: t.Logf, + h := &Holder{Away: tg, Desk: dt, Store: st, Now: c.now, Logf: t.Logf, Emit: func(e string, _ any) error { emitted = append(emitted, e); return nil }} h.init() return h, tg, dt, c, st @@ -163,7 +163,7 @@ func TestAWarningGoesToTheDesktopWhenASessionAnswersElseTelegram(t *testing.T) { func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) { h, tg, _, c, _ := newHolder(t) - h.Desktop = nil + h.Desk = nil // Urgent conditions, each alone and after a quiet half minute: each is its own message — until // the cap, which is said once, in a message of its own, and holds the rest. for i := 0; i < 25; i++ { @@ -241,7 +241,7 @@ func TestAMessageCarryingAnAddressIsRefusedAndSaidWithItsWordsWithheld(t *testin func TestAKeyThatCarriesAnAddressIsWithheldToo(t *testing.T) { h, tg, _, _, _ := newHolder(t) - h.Desktop = nil + h.Desk = nil h.Condition(EventRaised, cond("machine.10.0.0.7.silent", Urgent, "a machine is silent")) if len(tg.sends) != 1 || strings.Contains(tg.sends[0].Text(), "10.0.0.7") { t.Fatalf("sends: %+v", tg.sends) @@ -250,7 +250,7 @@ func TestAKeyThatCarriesAnAddressIsWithheldToo(t *testing.T) { func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) { h, tg, _, c, _ := newHolder(t) - h.Desktop = nil + h.Desk = nil u := cond("bus.controller.slow-consumer", Urgent, "the controller's consumer is far behind") u.Raised = c.now() w := cond("plan.41.stalled", Warning, "plan 41 waits") @@ -365,7 +365,7 @@ func TestEscalationIsSaidOnce(t *testing.T) { func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) { h, tg, _, c, _ := newHolder(t) - h.Desktop = nil + h.Desk = nil tg.fail = errors.New("telegram sendMessage: cannot connect (dial)") h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "silent")) st := h.Status("listening") @@ -396,11 +396,11 @@ func TestNothingConfiguredIsSaidInTheStatus(t *testing.T) { func TestARestartForgetsNothing(t *testing.T) { h, tg, _, c, st := newHolder(t) - h.Desktop = nil + h.Desk = nil k := cond("machine.ace.silent", Urgent, "silent") k.Raised = c.now() h.Condition(EventRaised, k) - again := &Holder{Telegram: tg, Store: st, Now: c.now, Logf: t.Logf} + again := &Holder{Away: tg, Store: st, Now: c.now, Logf: t.Logf} if err := again.Load(); err != nil { t.Fatal(err) } @@ -416,7 +416,7 @@ func TestARestartForgetsNothing(t *testing.T) { func TestAnUnreadableEventIsToldOnceAnHour(t *testing.T) { h, tg, _, c, _ := newHolder(t) - h.Desktop = nil + h.Desk = nil h.Unreadable("mesh-controller.condition-raised", errors.New("no severity")) h.Unreadable("mesh-controller.condition-raised", errors.New("no severity")) settle(h, c) @@ -433,7 +433,7 @@ func TestAnUnreadableEventIsToldOnceAnHour(t *testing.T) { func TestNotifyIsKeptApartFromConditions(t *testing.T) { h, tg, _, c, _ := newHolder(t) - h.Desktop = nil + h.Desk = nil out, err := h.Notify("backup.ace.failed", Warning, "last night's backup of the home server failed", "backup", false) if err != nil || out["held"] != true { t.Fatalf("%v %v", out, err) diff --git a/modules/messenger/cmd/messenger/link.go b/modules/messenger/cmd/messenger/link.go new file mode 100644 index 00000000..d321c848 --- /dev/null +++ b/modules/messenger/cmd/messenger/link.go @@ -0,0 +1,264 @@ +package main + +// Who the operator is on a channel (novox/hq ADR 0259 §7): the router's list of the operator's +// identities, one per kind, changed only by linking and unlinking — never by a setting, which any +// principal granted `settings` could write. +// +// - **Linking:** the operator sends the channel's bot `/start`; the channel says `link.` with the +// sender as its service authenticated it. The router shows a six-digit code on the desk, with the name +// the service shows, for ten minutes. The operator sends the code to the bot; the channel asks it as a +// proof (`proof.code.`: request and reply, never kept). When it matches, the account is linked, +// and said on every channel. +// - **A new link acknowledges at once and approves only after an hour** (LinkedApproveAfter), decided +// by the operator on 2026-10-08 until an authenticator-app code exists: the hour lets the operator +// unlink an account an agent linked by reading the code off the X11 desk. +// - **One identity per kind.** A second link of a kind is refused until the first is unlinked; unlinking +// is said on every channel, the unlinked one included. +// - Only a kind whose claim declares `verified-sender` can be linked: the desk proves nothing about who +// clicked, so it has no identities and answers acknowledgements only. +// - The code is kept only as a salted hash; five wrong tries end the link asked for. + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +const ( + // LinkCodeFor is how long a link's code is good for. + LinkCodeFor = 10 * time.Minute + // LinkTries is how many wrong codes end a link asked for. + LinkTries = 5 +) + +// PendingLink is a link asked for and not yet confirmed: who asked, and the code's hash. +type PendingLink struct { + Kind string `json:"kind"` + Identity string `json:"identity"` + Display string `json:"display,omitempty"` + Salt string `json:"salt"` + CodeHash string `json:"code-hash"` + Expires time.Time `json:"expires"` + Tries int `json:"tries"` +} + +func identityKey(kind, identity string) string { return kind + "." + kvKey(identity) } + +func hashCode(salt, code string) string { + sum := sha256.Sum256([]byte(salt + ":" + code)) + return hex.EncodeToString(sum[:]) +} + +// identity is the linked account of a kind, when this sender is it. +func (h *Holder) identity(kind, identity string) *Identity { + if h.Asks == nil || identity == "" { + return nil + } + id, err := h.Asks.GetIdentity(identityKey(kind, identity)) + if err != nil || id == nil || id.Kind != kind || id.Identity != identity { + return nil + } + return id +} + +// identitiesOf is every account linked on a kind. +func (h *Holder) identitiesOf(kind string) []Identity { + if h.Asks == nil { + return nil + } + keys, err := h.Asks.IdentityKeys() + if err != nil { + return nil + } + var out []Identity + for _, k := range keys { + if !strings.HasPrefix(k, kind+".") { + continue + } + if id, err := h.Asks.GetIdentity(k); err == nil && id != nil && id.Kind == kind { + out = append(out, *id) + } + } + return out +} + +func (h *Holder) linked(kind string) bool { return len(h.identitiesOf(kind)) > 0 } + +// Link takes a link asked for on a kind: a code shown on the desk, or a refusal said on the kind. +func (h *Holder) Link(kind string, l asks.LinkAsked) string { + h.work.Lock() + defer h.work.Unlock() + reply := func(title, body string) string { + _ = h.showOn(kind, Message{Title: title, Body: body, Quiet: true, Reply: l.ID}) + return title + } + if h.Bench == nil || !h.Bench.Promises(kind, "verified-sender") { + return reply("This channel cannot be linked", "It cannot prove who sends to it, so it answers only what anyone at the desk could.") + } + if !l.Sender.Authenticated || l.Sender.Identity == "" { + return reply("Not linked", "The service did not say who you are.") + } + if now := h.Now(); now.Sub(l.At) > FreshFor { + h.Logf("[messenger] a link asked on %s at %s is too old to act on", kind, l.At.Format(time.RFC3339)) + return "too old" + } + for _, id := range h.identitiesOf(kind) { + if id.Identity == l.Sender.Identity { + return reply("Already linked", "This account is already linked as the operator.") + } + return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked from the mesh MCP server (messenger_unlink), and then this one can be linked.") + } + if h.Desk == nil || h.Desk.Ready() != nil { + return reply("Not linked", "No desk can show the code now. Try again when you are at your machine.") + } + code, err := sixDigits() + if err != nil { + return reply("Not linked", "No code could be made.") + } + salt := ticket() + p := PendingLink{Kind: kind, Identity: l.Sender.Identity, Display: l.Sender.Display, Salt: salt, + CodeHash: hashCode(salt, code), Expires: h.Now().Add(LinkCodeFor)} + if err := h.Asks.PutLink(kind, p); err != nil { + return reply("Not linked", "The link could not be recorded: "+err.Error()) + } + who := l.Sender.Display + if who == "" { + who = "an account" + } + desk := Message{Title: "Link a " + Display(kind) + " account as you", + Body: fmt.Sprintf("The %s account %q asked to be linked as you. If that is you, send this code to the bot "+ + "before %s: %s %s. If it is not you, do nothing: nothing is linked.", Display(kind), who, + p.Expires.In(h.zone()).Format("15:04"), code[:3], code[3:]), + Urgent: true} + if _, err := h.Desk.Send(desk); err != nil { + _ = h.Asks.DeleteLink(kind) + return reply("Not linked", "The desk could not show the code: "+err.Error()) + } + h.Logf("[messenger] a link was asked on %s by %q; its code is shown on the desk", kind, who) + return reply("Send the code shown on your desk", "Six digits, within ten minutes.") +} + +// Prove answers a code sent on a kind: the link it confirms, or why not. Never said in an event. +func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer { + h.work.Lock() + defer h.work.Unlock() + no := func(words string) asks.ProofAnswer { return asks.ProofAnswer{Accepted: false, Words: words} } + if c.Purpose != "" && c.Purpose != "link" { + return no("The mesh takes no code for that yet.") + } + if h.Asks == nil { + return no("Nothing can be linked here.") + } + p, err := h.Asks.GetLink(kind) + if err != nil || p == nil { + return no("No link was asked for. Send /start to begin.") + } + now := h.Now() + if !now.Before(p.Expires) { + _ = h.Asks.DeleteLink(kind) + return no("The code expired. Send /start for a new one.") + } + if c.Sender.Identity != p.Identity || !c.Sender.Authenticated { + return no("This code was asked for by another account.") + } + code := strings.ReplaceAll(strings.TrimSpace(c.Code), " ", "") + if subtle.ConstantTimeCompare([]byte(hashCode(p.Salt, code)), []byte(p.CodeHash)) != 1 { + p.Tries++ + if p.Tries >= LinkTries { + _ = h.Asks.DeleteLink(kind) + h.Logf("[messenger] a link on %s ended after %d wrong codes", kind, p.Tries) + return no("That code is not right, and that was the last try. Send /start for a new one.") + } + _ = h.Asks.PutLink(kind, *p) + return no("That code is not right.") + } + id := Identity{Kind: kind, Identity: p.Identity, Display: p.Display, Linked: now, ApproveFrom: now.Add(LinkedApproveAfter)} + if err := h.Asks.PutIdentity(identityKey(kind, p.Identity), id); err != nil { + return no("The link could not be recorded: " + err.Error()) + } + _ = h.Asks.DeleteLink(kind) + from := id.ApproveFrom.In(h.zone()).Format("15:04") + h.announce(Message{Title: "A " + Display(kind) + " account is now linked as you", + Body: fmt.Sprintf("The %s account %q can answer acknowledgements now, and approve from %s. If this was not "+ + "you, unlink it from the mesh MCP server (messenger_unlink) before then.", Display(kind), p.Display, from), + Urgent: true}) + h.record(Sent{At: now, Channel: kind, Key: "(link)", What: "linked " + p.Display, Outcome: "linked"}) + h.Logf("[messenger] linked %q on %s; it approves from %s", p.Display, kind, from) + return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."} +} + +// Unlink removes every account linked on a kind, said on every channel. +func (h *Holder) Unlink(kind string) (map[string]any, error) { + h.work.Lock() + defer h.work.Unlock() + if h.Asks == nil { + return nil, errorf("nothing is linked here") + } + ids := h.identitiesOf(kind) + if len(ids) == 0 { + return map[string]any{"kind": kind, "unlinked": []string{}}, nil + } + var names []string + for _, id := range ids { + if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil { + return nil, err + } + names = append(names, id.Display) + } + h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more", + Body: "Unlinked: " + strings.Join(names, ", ") + ". Answers that need you to be proven cannot be given on " + + Display(kind) + " until an account is linked again.", Urgent: true}) + h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: strings.Join(names, ", ")}) + return map[string]any{"kind": kind, "unlinked": names}, nil +} + +// Identities is every linked account, by kind. +func (h *Holder) Identities() []Identity { + if h.Asks == nil { + return nil + } + keys, _ := h.Asks.IdentityKeys() + var out []Identity + for _, k := range keys { + if id, err := h.Asks.GetIdentity(k); err == nil && id != nil { + out = append(out, *id) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Kind+out[i].Identity < out[j].Kind+out[j].Identity }) + return out +} + +// announce says a message on every kind that can send, at once: a link or an unlink is seen everywhere. +func (h *Holder) announce(m Message) { + if h.Bench == nil { + return + } + for _, k := range h.Bench.Kinds() { + if h.Bench.Ready(k.Kind) == nil { + _ = h.showOn(k.Kind, m) + } + } +} + +func sixDigits() (string, error) { + n, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", n.Int64()), nil +} + +// decodeLink reads a link asked for. +func decodeLink(body json.RawMessage) (asks.LinkAsked, error) { + var l asks.LinkAsked + return l, json.Unmarshal(body, &l) +} diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index 3ace7170..63bd28fd 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -1,7 +1,7 @@ -// messenger: the holder of the operator-channel seat (novox/hq to-be 45 §5, ADR 0227, research 028). -// A Go bundle the node's runtime launches. It consumes the controller's condition events and decides -// what is said to the operator, on Telegram and on the desktop notifier of the machine the operator -// is at. It keeps its open messages in its own state, so a restart forgets nothing. stdout is the MCP +// messenger: the holder of the operator-channel seat, the router (novox/hq to-be 45 §5, to-be 46, ADR +// 0227, ADR 0259). A Go bundle the node's runtime launches. It consumes the controller's condition events and +// decides what is said to the operator, on the channel kinds the mesh holds — each a module of its own — and +// holds every ask, judges each answer and issues the warrant its asker acts on. It keeps its open messages in its own state, so a restart forgets nothing. stdout is the MCP // channel; what this module says, it says on stderr. package main @@ -17,6 +17,7 @@ import ( _ "time/tzdata" // the operator's zone is known whatever this machine has installed stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" ) func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) } @@ -25,8 +26,6 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } // Settings are the operator's values for this module, merged by the mesh into one JSON file. type Settings struct { - TelegramChatID string `json:"telegram-chat-id"` - DesktopMachines []string `json:"desktop-machines"` // TimeZone is the operator's, an IANA name such as Europe/Brussels: every time a message says is said // in it. Empty is this machine's own zone. TimeZone string `json:"time-zone"` @@ -54,27 +53,13 @@ func readSettings(path string) (Settings, error) { if err != nil { return s, err } - // The chat id may be given as a number. var loose map[string]any if err := json.Unmarshal(raw, &loose); err != nil { return s, fmt.Errorf("the settings file is not JSON: %v", err) } - switch v := loose["telegram-chat-id"].(type) { - case string: - s.TelegramChatID = strings.TrimSpace(v) - case float64: - s.TelegramChatID = fmt.Sprintf("%.0f", v) - } if tz, ok := loose["time-zone"].(string); ok { s.TimeZone = strings.TrimSpace(tz) } - if list, ok := loose["desktop-machines"].([]any); ok { - for _, m := range list { - if name, ok := m.(string); ok && strings.TrimSpace(name) != "" { - s.DesktopMachines = append(s.DesktopMachines, strings.TrimSpace(name)) - } - } - } return s, nil } @@ -137,6 +122,77 @@ func (stateStore) Recent() ([]Sent, error) { return s, json.Unmarshal(e.Value, &s) } +// askStore keeps the asks in the state `asks` and the operator's identities, and the links asked for, in +// `identities` (novox/hq ADR 0259). Each write that must win once is a compare-and-set. +type askStore struct{} + +// changed reads the runtime's refusal of a compare-and-set that lost as ErrChanged. +func changed(err error) error { + if err != nil && strings.Contains(err.Error(), "written by another writer first") { + return ErrChanged + } + return err +} + +func (askStore) GetAsk(key string) (*AskRecord, uint64, error) { + e, err := stdio.State("asks").Get(key) + if err != nil || e == nil { + return nil, 0, err + } + var r AskRecord + if err := json.Unmarshal(e.Value, &r); err != nil { + return nil, 0, fmt.Errorf("the ask kept as %s cannot be read: %v", key, err) + } + return &r, e.Revision, nil +} +func (askStore) CreateAsk(key string, r AskRecord) (uint64, error) { + rev, err := stdio.State("asks").Create(key, r) + return rev, changed(err) +} +func (askStore) UpdateAsk(key string, r AskRecord, revision uint64) (uint64, error) { + rev, err := stdio.State("asks").Update(key, r, revision) + return rev, changed(err) +} +func (askStore) AskKeys() ([]string, error) { return stdio.State("asks").Keys() } +func (askStore) GetIdentity(key string) (*Identity, error) { + e, err := stdio.State("identities").Get(key) + if err != nil || e == nil { + return nil, err + } + var id Identity + return &id, json.Unmarshal(e.Value, &id) +} +func (askStore) PutIdentity(key string, id Identity) error { + _, err := stdio.State("identities").Put(key, id) + return err +} +func (askStore) DeleteIdentity(key string) error { return stdio.State("identities").Delete(key) } +func (askStore) IdentityKeys() ([]string, error) { + keys, err := stdio.State("identities").Keys() + var out []string + for _, k := range keys { + if !strings.HasPrefix(k, "pending.") { + out = append(out, k) + } + } + return out, err +} +func (askStore) GetLink(kind string) (*PendingLink, error) { + e, err := stdio.State("identities").Get("pending." + kind) + if err != nil || e == nil { + return nil, err + } + var l PendingLink + return &l, json.Unmarshal(e.Value, &l) +} +func (askStore) PutLink(kind string, l PendingLink) error { + _, err := stdio.State("identities").Put("pending."+kind, l) + return err +} +func (askStore) DeleteLink(kind string) error { + return stdio.State("identities").Delete("pending." + kind) +} + // listening is whether condition events reach this holder, in words. type listening struct { mu sync.Mutex @@ -163,18 +219,17 @@ func main() { } return s } + bench := NewBench(time.Now) + publish := func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) } h := &Holder{ - Telegram: NewTelegram(TelegramConfig{ - TokenFile: os.Getenv("MESH_MESSENGER_TELEGRAM_TOKEN_FILE"), - ChatID: func() string { return settings().TelegramChatID }, - }), - Desktop: &Desktop{ - Machines: func() []string { return settings().DesktopMachines }, - Ask: stdio.Ask, - }, - Store: stateStore{}, - Ask: stdio.Ask, - Logf: logf, + Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish}, + Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, + Store: stateStore{}, + Asks: askStore{}, + Bench: bench, + Publish: publish, + Ask: stdio.Ask, + Logf: logf, Zone: func() *time.Location { loc, err := zoneOf(settings().TimeZone) said.Lock() @@ -190,6 +245,12 @@ func main() { }, Emit: func(event string, body any) error { return stdio.Emit(event, body) }, } + bench.To = func(kind string) string { + if ids := h.identitiesOf(kind); len(ids) > 0 { + return ids[0].Identity + } + return "" + } h.init() l := &listening{now: "not yet: starting"} go run(h, l) @@ -250,7 +311,10 @@ func run(h *Holder, l *listening) { h.Condition(event, c) return nil } - go listenForAnswers(h) + go listenForChannels(h) + go takeAsks(h) + go answerProofs(h) + go followKinds(h) for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { err := stdio.Subscribe(ControllerSeat+".*", handle) if err == nil { @@ -264,61 +328,137 @@ func run(h *Holder, l *listening) { } } -// AnswerEvent is the desk's notifier saying which answer was chosen, and StartedEvent that it started (so -// the answers it waited for are lost): the dunst holder of node-notifier emits them as its module's events -// until seats publish events of their own (to-be 46 phase 3). -const ( - AnswerEvent = "dunst.action-chosen" - StartedEvent = "dunst.started" -) +// kindOf is an intake event's kind and what it is: `intake..`, the kind the subject's last token, +// which the bus grants that kind's holder alone. +func kindOf(key string) (what, kind string, ok bool) { + parts := strings.Split(key, ".") + if len(parts) != 3 || parts[0] != asks.IntakeSeat { + return "", "", false + } + return parts[1], parts[2], true +} -// listenForAnswers takes the answers the operator chooses on the desk, retried until it can. -func listenForAnswers(h *Holder) { +// listenForChannels takes what the channels say (novox/hq ADR 0259): answers chosen, links asked, messages +// that failed, and whether each can send — every one under the kind the bus granted its holder. +func listenForChannels(h *Holder) { handle := func(e stdio.Envelope) error { - if e.Key == StartedEvent { - var body struct { - Machine string `json:"machine"` + what, kind, ok := kindOf(e.Key) + if !ok { + return nil + } + switch what { + case asks.Chosen: + var c asks.Choice + if err := json.Unmarshal(e.Body, &c); err != nil || c.Ticket == "" { + logf("[messenger] an answer from %s could not be read: %s", kind, firstLineOf(string(e.Body))) + return nil } - _ = json.Unmarshal(e.Body, &body) - if body.Machine == "" { - body.Machine = e.Node + if c.ID == "" { + c.ID = e.Headers["x-event-id"] + } + _, _ = h.Chosen(kind, c) + case asks.Link: + l, err := decodeLink(e.Body) + if err != nil { + logf("[messenger] a link asked on %s could not be read", kind) + return nil + } + h.Link(kind, l) + case asks.FailedWhat: + var f asks.Failed + if json.Unmarshal(e.Body, &f) == nil { + h.Failed(kind, f) + } + case asks.StandingWhat: + var s asks.Standing + if json.Unmarshal(e.Body, &s) == nil { + h.Bench.Stand(kind, Standing{Ready: s.Ready, Why: s.Why, EditsSilently: s.EditsSilently, At: s.At}) } - h.Reoffer(body.Machine) - return nil } - if e.Key != AnswerEvent { - return nil - } - var body struct { - Token string `json:"token"` - Machine string `json:"machine"` - } - if err := json.Unmarshal(e.Body, &body); err != nil || body.Token == "" { - logf("[messenger] an answer from the desk could not be read: %s", firstLineOf(string(e.Body))) - return nil - } - machine := body.Machine - if machine == "" { - machine = e.Node - } - // The machine is the event's own node, said by the runtime: the body's word is only a hint. - if e.Node != "" { - machine = e.Node - } - _, _ = h.Chosen("desktop", machine, body.Token, e.Headers["x-event-id"]) return nil } for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { - if err := stdio.Subscribe("dunst.*", handle); err == nil { - logf("[messenger] listening for the operator's answers on the desk") - return + if err := stdio.Subscribe(asks.IntakeSeat+".#", handle); err == nil { + logf("[messenger] listening for what the channels say") + break } else { - logf("[messenger] not hearing the desk's answers yet (%v); asking again in %s", err, wait) + logf("[messenger] not hearing the channels yet (%v); asking again in %s", err, wait) } time.Sleep(wait) } } +// takeAsks takes the asks and cancels submitted to the seat, each under its asker's name. +func takeAsks(h *Holder) { + handle := func(w stdio.Work) error { + parts := strings.Split(w.Subject, ".") + if len(parts) != 6 { + logf("[messenger] work on %s is not an ask; taken and dropped", w.Subject) + return nil + } + return h.TakeAsk(parts[4], parts[5], w.Body) + } + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + if err := stdio.SeatTake(AsksWorker, handle); err == nil { + logf("[messenger] taking the asks submitted to %s", asks.Seat) + break + } else { + logf("[messenger] not taking asks yet (%v); asking again in %s", err, wait) + } + time.Sleep(wait) + } +} + +// AsksWorker is the worker the seat's asks are taken from, as the controller names it. +const AsksWorker = "SEAT_OPERATOR_CHANNEL_worker" + +// answerProofs answers the codes the channels ask as proofs: never an event, never kept. +func answerProofs(h *Holder) { + handle := func(subject string, body json.RawMessage) (any, error) { + parts := strings.Split(subject, ".") + kind := parts[len(parts)-1] + var c asks.Code + if err := json.Unmarshal(body, &c); err != nil { + return asks.ProofAnswer{Words: "The code could not be read."}, nil + } + return h.Prove(kind, c), nil + } + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + if err := stdio.SeatAnswer(asks.ProofSubject(asks.CodeProof, "*"), handle); err == nil { + logf("[messenger] answering the codes the channels are sent") + break + } else { + logf("[messenger] not answering codes yet (%v); asking again in %s", err, wait) + } + time.Sleep(wait) + } +} + +// followKinds reads, every half minute, which channel kinds the controller says exist and what each +// promises: never from a channel. +func followKinds(h *Holder) { + said := "" + for { + kinds, err := stdio.SeatKinds() + if err != nil { + if err.Error() != said { + logf("[messenger] which channels exist cannot be read (%v); asking again", err) + said = err.Error() + } + } else { + var out []KindInfo + for _, k := range kinds { + if k.Seat == asks.ChannelSeat { + out = append(out, KindInfo{Kind: k.Kind, Module: k.Module, Node: k.Node, Capabilities: k.Capabilities}) + } + } + h.Bench.SetKinds(out) + said = "" + } + time.Sleep(30 * time.Second) + } +} + // syncFromController reads the controller's open conditions and gives them to the holder. func syncFromController(h *Holder) { asked := h.Now() @@ -398,34 +538,40 @@ func tools(h *Holder, l *listening) []stdio.Tool { strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved")) }}, {Name: "messenger_status", - Description: "Whether the operator can be reached, and why not: each channel — can it send, what it lacks " + - "(the Telegram bot token and chat id, the desktop machines), its last error, how many it sent in the " + - "last hour against the cap, how many it holds — the open and silenced count, messages not sent yet, " + - "refusals, unreadable events, whether condition events arrive, and the rules it applies. check asks " + - "Telegram who the bot is, sending nothing.", - Input: map[string]any{"check": map[string]any{"type": "boolean", "description": "also ask Telegram whether the token works"}}, + Description: "Whether the operator can be reached, and why not: each channel kind the mesh holds — can it " + + "send, what it said of itself, its last error, how many it sent in the last hour against the cap, how " + + "many it holds — the open and silenced count, messages not sent yet, refusals, unreadable events, " + + "whether condition events arrive, and the rules it applies.", + Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }}, + {Name: "operator-channel.asks", + Description: "The asks this router holds (novox/hq ADR 0259): open first, then the last month's — each " + + "one's asker, headline, state, when it opened and expires, the channels it was offered on, how it " + + "ended (the warrant: who chose what, through which channel), and whether its asker was told.", + Run: func(map[string]any) (any, error) { return h.AsksNow(), nil }}, + {Name: "messenger_identities", + Description: "The accounts linked as the operator, one per channel kind: the account, the name its " + + "service shows, when it was linked, and from when it can approve (an hour after linking).", + Run: func(map[string]any) (any, error) { return h.Identities(), nil }}, + {Name: "messenger_unlink", + Description: "Unlink the account linked as the operator on one channel kind: from then its answers are " + + "refused until an account is linked again. Said on every channel, the unlinked one included.", + Input: map[string]any{"kind": str("the channel kind, e.g. telegram")}, Run: func(a map[string]any) (any, error) { - st := h.Status(l.get()) - if check, _ := a["check"].(bool); check { - if t, ok := h.Telegram.(*Telegram); ok { - who, err := t.Who() - if err != nil { - return map[string]any{"status": st, "telegram_check": "failed: " + err.Error()}, nil - } - return map[string]any{"status": st, "telegram_check": "the token works: the bot is @" + who}, nil - } + kind := strArg(a, "kind") + if kind == "" { + return nil, errorf("kind is required") } - return st, nil + return h.Unlink(kind) }}, {Name: "messenger_recent", Description: "The recent sends — each message, digest, edit and failure on each channel, newest first — and the refusals.", Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many (default 20)"}}, Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 20)), nil }}, {Name: "messenger_test", - Description: "Send a test message now, to telegram, desktop or both (default both), through the content rule " + - "and the cap: proves a channel reaches the operator. Answers per channel: sent, or why not.", + Description: "Send a test message now, to one channel kind or every one (default), through the content rule " + + "and the cap: proves a channel reaches the operator. Answers per channel: put on its queue, or why not.", Input: map[string]any{ - "channel": map[string]any{"type": "string", "enum": []string{"telegram", "desktop", "both"}}, + "channel": str("a channel kind, e.g. telegram, or empty for every one"), "text": str("the words (default: a line saying it is a test)"), }, Run: func(a map[string]any) (any, error) { return h.Test(strArg(a, "channel"), strArg(a, "text")), nil }}, diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 13a63d2e..48bef375 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -8,20 +8,24 @@ import ( "sort" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/asks" ) -// The manifest says what the code does: the seat it declares and holds and the verbs it serves, the -// events it consumes (the controller's three), the one it emits, the tool it calls, its state, its -// secret, and its own tools — and names nothing of one installation. +// The manifest says what the code does (novox/hq ADR 0259): the seats it declares — the operator's, and +// the two kinded benches its channels hold — the one it holds and the verbs it serves, the bench it sends +// on, the events it consumes, the one it emits, the tool it calls, its state, and its own tools. It holds +// no channel's secret, and names nothing of one installation. type manifest struct { Module string `json:"module"` Seats []seat `json:"seats"` Claims []seat `json:"claims"` + Uses []string Consumes []string Emits []string Invokes []string - State []string + State []json.RawMessage Own map[string]string `json:"own-secrets"` Tools []string Build struct { @@ -30,9 +34,15 @@ type manifest struct { } type seat struct { - Name string `json:"name"` - Scope string `json:"scope"` - Serves []string `json:"serves"` + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` + Accepts []string `json:"accepts"` + Emits []string `json:"emits"` + Kinded bool `json:"kinded"` + ByCaller []string `json:"by-caller"` + Proofs []string `json:"proofs"` + Records []string `json:"records"` } func readManifest(t *testing.T) (manifest, string) { @@ -48,29 +58,49 @@ func readManifest(t *testing.T) (manifest, string) { return m, string(raw) } -func TestItDeclaresAndHoldsTheOperatorChannel(t *testing.T) { +func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { m, _ := readManifest(t) - want := seat{Name: "operator-channel", Scope: "mesh", Serves: []string{"open", "history", "notify"}} - if len(m.Seats) != 1 || !reflect.DeepEqual(m.Seats[0], want) || len(m.Claims) != 1 || !reflect.DeepEqual(m.Claims[0], want) { - t.Fatalf("seats %+v claims %+v", m.Seats, m.Claims) + verbs := []string{"open", "history", "notify", "asks"} + want := []seat{ + {Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}}, + {Name: asks.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{asks.Show, asks.Edit, asks.Send}}, + {Name: asks.IntakeSeat, Scope: "mesh", Kinded: true, + Emits: []string{asks.Chosen, asks.Link, asks.FailedWhat, asks.StandingWhat}, Proofs: []string{asks.CodeProof}}, + } + if !reflect.DeepEqual(m.Seats, want) { + t.Fatalf("seats %+v", m.Seats) + } + if len(m.Claims) != 1 || m.Claims[0].Name != asks.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) { + t.Fatalf("claims %+v", m.Claims) + } + if !reflect.DeepEqual(m.Uses, []string{asks.ChannelSeat}) { + t.Fatalf("uses %v", m.Uses) } if !reflect.DeepEqual(m.Consumes, []string{ ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared, - AnswerEvent, StartedEvent}) { + "intake.choice", "intake.link", "intake.failed", "intake.standing"}) { t.Fatalf("consumes %v", m.Consumes) } - if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions"}) { + // It performs nothing: the one verb it calls reads the open conditions. + if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions"}) { t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes) } - if !reflect.DeepEqual(m.State, []string{"open", "sent"}) { - t.Fatalf("state %v", m.State) + var state []string + for _, s := range m.State { + var name string + if json.Unmarshal(s, &name) != nil { + var full struct{ Name string } + _ = json.Unmarshal(s, &full) + name = full.Name + } + state = append(state, name) } - if m.Own["telegram-token"] == "" { - t.Fatalf("own secrets %v", m.Own) + if !reflect.DeepEqual(state, []string{"open", "sent", "asks", "identities"}) { + t.Fatalf("state %v", state) } - env, _ := m.Build.Artifacts[0]["env"].(map[string]any) - if env["MESH_MESSENGER_TELEGRAM_TOKEN_FILE"] != m.Own["telegram-token"] { - t.Fatalf("the bundle reads its token from %v, the secret is at %v", env["MESH_MESSENGER_TELEGRAM_TOKEN_FILE"], m.Own["telegram-token"]) + if len(m.Own) != 0 { + t.Fatalf("it holds a channel's secret: %v", m.Own) } } @@ -99,7 +129,7 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) { if !reflect.DeepEqual(verbs, m.Claims[0].Serves) { t.Fatalf("verbs %v, claim %v", verbs, m.Claims[0].Serves) } - for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox", "zurag", "api.telegram"} { + for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox", "zurag", "telegram"} { if strings.Contains(strings.ToLower(raw), never) { t.Errorf("module.json names %q", never) } diff --git a/modules/messenger/cmd/messenger/outbox.go b/modules/messenger/cmd/messenger/outbox.go index cdb29ff2..4ae846e1 100644 --- a/modules/messenger/cmd/messenger/outbox.go +++ b/modules/messenger/cmd/messenger/outbox.go @@ -33,7 +33,15 @@ func (h *Holder) deliver(r *Record, what string) { return } var to []Channel - if r.Severity == Urgent { + if kinds := h.askKinds(r); len(kinds) > 0 { + // An ask goes to every channel that carries one of its answers (novox/hq ADR 0259): the desk for an + // acknowledgement, the channel that proves its sender for an approval — whatever its severity. + for _, ch := range h.channels() { + if kinds[ch.Name()] && ch.Ready() == nil { + to = append(to, ch) + } + } + } else if r.Severity == Urgent { for _, ch := range h.channels() { if ch.Ready() == nil { to = append(to, ch) @@ -41,10 +49,10 @@ func (h *Holder) deliver(r *Record, what string) { } } else { switch { - case h.Desktop != nil && h.Desktop.Ready() == nil: - to = []Channel{h.Desktop} - case h.Telegram != nil && h.Telegram.Ready() == nil: - to = []Channel{h.Telegram} + case h.Desk != nil && h.Desk.Ready() == nil: + to = []Channel{h.Desk} + case h.Away != nil && h.Away.Ready() == nil: + to = []Channel{h.Away} } } if len(to) == 0 { @@ -113,7 +121,6 @@ func (h *Holder) queued(key string) bool { // edit fails, or that said it inside a digest, is given a new message instead — held like any other. // silentOnly: only where an edit notifies nobody; elsewhere nothing. func (h *Holder) edit(r *Record, what string, silentOnly bool) { - m := h.say(r, what) h.mu.Lock() sent := map[string]string{} for k, v := range r.Sent { @@ -131,7 +138,7 @@ func (h *Holder) edit(r *Record, what string, silentOnly bool) { continue } if id != "" && ch.CanEdit() { - err := ch.Edit(id, m) + err := ch.Edit(id, h.sayOn(r, what, ch.Name())) h.noteChannel(ch.Name(), err) if err == nil { h.record(Sent{At: now, Channel: ch.Name(), Key: r.Key, What: what, Outcome: "edited"}) @@ -204,14 +211,14 @@ func (h *Holder) due(ch Channel, list []item, now time.Time) bool { if now.Sub(oldest) < BurstWindow { return false } - if ch == h.Desktop && !lastWarn.IsZero() && now.Sub(lastWarn) < DesktopWarningEvery { + if ch == h.Desk && !lastWarn.IsZero() && now.Sub(lastWarn) < DesktopWarningEvery { return false } return true } // line is what an item says, resolved against the record now: nil when it is no longer to be said. -func (h *Holder) resolve(it item, now time.Time) (*Record, *Message) { +func (h *Holder) resolve(it item, now time.Time, kind string) (*Record, *Message) { if it.Msg != nil { return nil, it.Msg } @@ -224,7 +231,7 @@ func (h *Holder) resolve(it item, now time.Time) (*Record, *Message) { if (it.What == "cleared") != !r.Cleared.IsZero() { return nil, nil } - m := h.say(r, it.What) + m := h.sayOn(r, it.What, kind) return r, &m } @@ -238,7 +245,7 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { } var entries []entry for _, it := range list { - r, m := h.resolve(it, now) + r, m := h.resolve(it, now, name) if m == nil { continue } @@ -341,7 +348,7 @@ func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool { h.lastSend[name] = now h.lastTry[name] = time.Time{} for _, e := range entries { - if ch == h.Desktop && !e.it.urgent() { + if ch == h.Desk && !e.it.urgent() { // The desktop's warning rhythm counts from a message that carried a warning. h.lastWarn[name] = now } @@ -404,14 +411,14 @@ func (h *Holder) failed(ch Channel, list []item, taken int, now time.Time, err e h.lastTry[name] = now h.mu.Unlock() h.record(Sent{At: now, Channel: name, Key: "(held)", What: fmt.Sprintf("%d held", taken), Outcome: "failed: " + err.Error()}) - if ch == h.Desktop && h.Telegram != nil && h.Telegram.Ready() == nil { + if ch == h.Desk && h.Away != nil && h.Away.Ready() == nil { var keep []item for _, it := range list[:taken] { if it.Severity == Urgent { keep = append(keep, it) continue } - h.enqueue(h.Telegram.Name(), it) + h.enqueue(h.Away.Name(), it) } h.mu.Lock() h.outbox[name] = append(keep, h.outbox[name][taken:]...) diff --git a/modules/messenger/cmd/messenger/seatchannel.go b/modules/messenger/cmd/messenger/seatchannel.go new file mode 100644 index 00000000..56049858 --- /dev/null +++ b/modules/messenger/cmd/messenger/seatchannel.go @@ -0,0 +1,304 @@ +package main + +// The channels, as the router sees them (novox/hq ADR 0259): kinds of the `channel` bench, each held by a +// module of its own. The router keeps no channel's client and knows no service. What it knows of a kind +// comes from two places, never from one alone: +// +// - **the controller**, in this module's membership: which kinds exist, which module holds each, and the +// capabilities its claim declares (`channel-capabilities/1`). An answer is judged by these. +// - **the channel**, on the `intake` bench under its own kind: whether it can send now (`standing`), and +// a message it could not deliver (`failed`). +// +// A message is sent by putting it on the kind's work queue (`mesh.seat.channel.accept..`) under +// a handle the router makes; the channel keeps which of its own messages that handle is, so an edit names +// the handle. A queued message survives the channel being away. + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// StandingFresh is how long a channel's word on whether it can send is believed: a channel says it at +// least every five minutes, so a word older than this is a channel that stopped saying. +const StandingFresh = 15 * time.Minute + +// KindInfo is one kind of the channel bench as the controller issued it. +type KindInfo struct { + Kind string + Module string + Node string + Capabilities []string +} + +// Standing is what a channel last said of itself. +type Standing struct { + Ready bool `json:"ready"` + Why string `json:"why,omitempty"` + EditsSilently bool `json:"edits-silently,omitempty"` + At time.Time `json:"at"` +} + +// Bench is every kind the router may send to, and what each said. +type Bench struct { + Now func() time.Time + // To is the account linked as the operator on a kind, where it verifies its sender: where its messages go. + To func(kind string) string + + mu sync.Mutex + kinds map[string]KindInfo + standing map[string]Standing + known bool +} + +// NewBench is a bench that knows no kind yet. +func NewBench(now func() time.Time) *Bench { + return &Bench{Now: now, kinds: map[string]KindInfo{}, standing: map[string]Standing{}} +} + +// SetKinds takes the kinds the controller issued. +func (b *Bench) SetKinds(kinds []KindInfo) { + b.mu.Lock() + defer b.mu.Unlock() + b.kinds = map[string]KindInfo{} + for _, k := range kinds { + b.kinds[k.Kind] = k + } + b.known = true +} + +// Stand takes what a kind said of itself. +func (b *Bench) Stand(kind string, s Standing) { + b.mu.Lock() + defer b.mu.Unlock() + b.standing[kind] = s +} + +// Kind is one kind, as the controller issued it. +func (b *Bench) Kind(kind string) (KindInfo, bool) { + b.mu.Lock() + defer b.mu.Unlock() + k, ok := b.kinds[kind] + return k, ok +} + +// Kinds is every kind, by name. +func (b *Bench) Kinds() []KindInfo { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]KindInfo, 0, len(b.kinds)) + for _, k := range b.kinds { + out = append(out, k) + } + sort.Slice(out, func(i, j int) bool { return out[i].Kind < out[j].Kind }) + return out +} + +// Promises says whether a kind's claim declares a capability: the controller's record, never the channel's. +func (b *Bench) Promises(kind, capability string) bool { + k, ok := b.Kind(kind) + if !ok { + return false + } + for _, c := range k.Capabilities { + if c == capability { + return true + } + } + return false +} + +// Ready says whether a kind can send now, in words when it cannot. +func (b *Bench) Ready(kind string) error { + b.mu.Lock() + _, held := b.kinds[kind] + s, said := b.standing[kind] + known := b.known + b.mu.Unlock() + switch { + case !held && !known: + return errors.New("the mesh has not yet said which channels exist") + case !held: + return fmt.Errorf("no module holds the channel %s", kind) + case !said: + return fmt.Errorf("%s has not said whether it can send", kind) + case b.Now().Sub(s.At) > StandingFresh: + return fmt.Errorf("%s has said nothing since %s", kind, s.At.UTC().Format(time.RFC3339)) + case !s.Ready: + if s.Why == "" { + return fmt.Errorf("%s says it cannot send", kind) + } + return errors.New(s.Why) + } + return nil +} + +func (b *Bench) editsSilently(kind string) bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.standing[kind].EditsSilently +} + +// Roles: the away channel reaches the operator away from the machines; the desk is at them. +const ( + RoleAway = "away" + RoleDesk = "desk" +) + +// roleOf is a kind's role, from what its claim declares. +func (b *Bench) roleOf(kind string) string { + if b.Promises(kind, "reaches-away") { + return RoleAway + } + return RoleDesk +} + +// Display is a kind as the operator reads it: "Telegram", "the desk". +func Display(kind string) string { + if kind == "desktop" { + return "the desk" + } + if kind == "" { + return kind + } + return strings.ToUpper(kind[:1]) + kind[1:] +} + +// SeatChannel is one role's channel: the first kind of that role that can send, else the first of it. +type SeatChannel struct { + Bench *Bench + Role string + Publish func(subject string, body any, id string) error +} + +func (c *SeatChannel) kind() string { + var first string + for _, k := range c.Bench.Kinds() { + if c.Bench.roleOf(k.Kind) != c.Role { + continue + } + if first == "" { + first = k.Kind + } + if c.Bench.Ready(k.Kind) == nil { + return k.Kind + } + } + return first +} + +// Name is the kind it sends to now, or its role when no kind holds it. +func (c *SeatChannel) Name() string { + if k := c.kind(); k != "" { + return k + } + return c.Role +} + +func (c *SeatChannel) Ready() error { + k := c.kind() + if k == "" { + if c.Role == RoleAway { + return errors.New("no channel reaching the operator away from the machines is held: a module claims a " + + "kind of the channel bench that promises reaches-away (novox/hq ADR 0259)") + } + return errors.New("no channel at the operator's machines is held") + } + return c.Bench.Ready(k) +} + +func (c *SeatChannel) CanEdit() bool { return c.Bench.Promises(c.kind(), "edit") } +func (c *SeatChannel) SilentEdit() bool { return c.Bench.editsSilently(c.kind()) } + +// Send puts a message on the kind's queue and answers the handle it is known by. +func (c *SeatChannel) Send(m Message) (string, error) { + handle := newHandle() + return handle, c.put(handle, m, false) +} + +// Edit replaces the message a handle names. +func (c *SeatChannel) Edit(handle string, m Message) error { + if handle == "" { + return errors.New("no message on record to replace") + } + return c.put(handle, m, true) +} + +func (c *SeatChannel) put(handle string, m Message, edit bool) error { + kind := c.kind() + if kind == "" { + return c.Ready() + } + return c.putOn(kind, handle, m, edit) +} + +func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error { + if c.Publish == nil { + return errors.New("this router cannot reach the bus") + } + verb := asks.Send + switch { + case edit: + verb = asks.Edit + case len(m.Actions) > 0: + verb = asks.Show + } + out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply} + if c.Bench.To != nil { + out.To = c.Bench.To(kind) + } + for _, a := range m.Actions { + out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token}) + } + return c.Publish(asks.ChannelSubject(verb, kind), out, handle+"."+fmt.Sprint(time.Now().UnixNano())) +} + +func newHandle() string { + var b [9]byte + _, _ = rand.Read(b[:]) + return "m" + hex.EncodeToString(b[:]) +} + +// Asker calls a tool through the mesh, as stdio.Ask does: how the open conditions are read. +type Asker func(key string, body any) (json.RawMessage, error) + +// KindChannel is one kind as a channel: for a test, and for the status of each. +type KindChannel struct { + Bench *Bench + Kind string + Publish func(subject string, body any, id string) error +} + +func (c *KindChannel) Name() string { return c.Kind } +func (c *KindChannel) Ready() error { return c.Bench.Ready(c.Kind) } +func (c *KindChannel) CanEdit() bool { return c.Bench.Promises(c.Kind, "edit") } +func (c *KindChannel) SilentEdit() bool { return c.Bench.editsSilently(c.Kind) } +func (c *KindChannel) Send(m Message) (string, error) { + s := &SeatChannel{Bench: c.Bench, Publish: c.Publish} + handle := newHandle() + return handle, s.putOn(c.Kind, handle, m, false) +} +func (c *KindChannel) Edit(handle string, m Message) error { + s := &SeatChannel{Bench: c.Bench, Publish: c.Publish} + return s.putOn(c.Kind, handle, m, true) +} + +// kindChannels is every kind the controller says exists, as a channel; the two roles when it says none yet. +func (h *Holder) kindChannels() []Channel { + if h.Bench == nil || len(h.Bench.Kinds()) == 0 { + return h.channels() + } + var out []Channel + for _, k := range h.Bench.Kinds() { + out = append(out, &KindChannel{Bench: h.Bench, Kind: k.Kind, Publish: h.Publish}) + } + return out +} diff --git a/modules/messenger/cmd/messenger/status.go b/modules/messenger/cmd/messenger/status.go index 8a824dd8..79851e7a 100644 --- a/modules/messenger/cmd/messenger/status.go +++ b/modules/messenger/cmd/messenger/status.go @@ -44,7 +44,7 @@ type Status struct { func (h *Holder) Status(listening string) Status { now := h.Now() var st Status - for _, ch := range h.channels() { + for _, ch := range h.kindChannels() { cs := ChannelStatus{Name: ch.Name(), Cap: CapPerHour, SentLastHour: h.sentLastHour(ch.Name())} if err := ch.Ready(); err != nil { cs.NotReady = err.Error() @@ -103,13 +103,17 @@ func (h *Holder) Status(listening string) Status { "history is never news: what is open is read from the controller on start, every 10 min and when an old event arrives; an event older than that reading is not acted on, one older than 10 min by its own time is state, never a message", "a clearing of something never said says nothing; an urgent condition open and never said is said, all of them in one message", "nothing is sent in the first minute after start but that one message", - "urgent: Telegram and the desktop; warning: the desktop where a session answers, otherwise Telegram", + "urgent: every channel kind that can send; warning: the desk where it can send, otherwise the channel that reaches the operator away", + "the channels are kinds of the channel bench, each a module of its own; which exist and what each promises is the controller's word, in this module's membership; whether each can send is its own (novox/hq ADR 0259)", + "an ask is held here and offered on each kind whose capabilities carry its answers; an answer is judged here and closes the ask with a warrant its asker acts on; nothing is performed here", + "an account linked as the operator acknowledges at once and approves an hour after it was linked; every link and unlink is said on every channel", + "the desk proves nothing about who clicked: it answers acknowledgements only", "bursts are one message: what arrives within 30 s goes out together, a digest when it is several; urgent goes at once when the channel was quiet for 30 s", - "the desktop is gentle: warnings at most one message every 15 min", + "the desk is gentle: warnings at most one message every 15 min", "deduplicated by the condition's key; reminded once after 1 h (urgent) or 12 h (warning); cleared by editing the first message", "at most 20 messages an hour per channel, a last line of defence: reached, it is said once and the rest held for one digest", "refused: anything carrying an address, a path or a secret; channel-refused is sent with the words withheld", - "silenced conditions send nothing; silence is `conditions silence` through the mesh", + "silenced conditions send nothing; a silence is the controller's, asked of the operator or set through the mesh", } anyCan := false for _, c := range st.Channels { @@ -218,7 +222,7 @@ func (h *Holder) Test(channel, text string) map[string]string { return out } m := Message{Title: "Test message: " + text, Body: "Nothing is wrong; this was asked for."} - for _, ch := range h.channels() { + for _, ch := range h.kindChannels() { if channel != "" && channel != "both" && channel != ch.Name() { continue } diff --git a/modules/messenger/cmd/messenger/telegram.go b/modules/messenger/cmd/messenger/telegram.go deleted file mode 100644 index 71c21094..00000000 --- a/modules/messenger/cmd/messenger/telegram.go +++ /dev/null @@ -1,206 +0,0 @@ -package main - -// The Telegram channel: a bot to the operator's chat (novox/hq to-be 45 §5, research 028/02). Sending -// needs only outbound HTTPS. The bot token is this module's own secret, accepted from the operator; the -// chat id is a setting. Neither is ever said: an error from the HTTP client carries the URL, and the -// URL carries the token, so every error is rebuilt here from its kind before it leaves this file. - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "net" - "net/http" - "net/url" - "os" - "regexp" - "strings" - "time" -) - -// TelegramAPI is where the bot API answers; a test points it elsewhere. -var TelegramAPI = "https://api.telegram.org" - -var ( - tokenShape = regexp.MustCompile(`^\d{5,}:[A-Za-z0-9_-]{30,}$`) - chatIDShape = regexp.MustCompile(`^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{4,})$`) -) - -// TelegramConfig is where the token is and what the chat is; read each time it is used, so a secret -// accepted or a setting changed takes effect at the next message without a restart. -type TelegramConfig struct { - TokenFile string - ChatID func() string -} - -// Telegram sends to one chat. -type Telegram struct { - Config TelegramConfig - Client *http.Client -} - -func NewTelegram(cfg TelegramConfig) *Telegram { - return &Telegram{Config: cfg, Client: &http.Client{Timeout: 20 * time.Second}} -} - -func (t *Telegram) Name() string { return "telegram" } - -// Ready says whether the channel can send, in words. -func (t *Telegram) Ready() error { - _, _, err := t.ready() - return err -} - -// ready says whether the channel can send, and when not, what the operator must give. The words name -// the setting and the secret, never a value. -func (t *Telegram) ready() (string, string, error) { - token, err := t.token() - if err != nil { - return "", "", err - } - chat := strings.TrimSpace(t.Config.ChatID()) - if chat == "" { - return "", "", errors.New("no chat to send to: the setting telegram-chat-id is not given " + - "(`settings` for messenger with {\"telegram-chat-id\": \"\"})") - } - if !chatIDShape.MatchString(chat) { - return "", "", errors.New("the setting telegram-chat-id is not a chat id (a number, or @name of a channel)") - } - return token, chat, nil -} - -func (t *Telegram) token() (string, error) { - if t.Config.TokenFile == "" { - return "", errors.New("no bot token: this module was started without a token file") - } - raw, err := os.ReadFile(t.Config.TokenFile) - if errors.Is(err, os.ErrNotExist) { - return "", errors.New("no bot token: the own secret telegram-token is not on this machine yet " + - "(`secret accept messenger telegram-token`, then push the machine)") - } - if err != nil { - return "", errors.New("the own secret telegram-token cannot be read: " + plainError(err)) - } - token := strings.TrimSpace(string(raw)) - if token == "" { - return "", errors.New("no bot token: the own secret telegram-token is empty") - } - if !tokenShape.MatchString(token) { - // The mesh mints a random value for an own secret nobody accepted; that is not a bot token. - return "", errors.New("the own secret telegram-token is not a bot token (digits, a colon, then the key " + - "BotFather gave) — most likely the mesh made it because none was accepted: " + - "`secret accept messenger telegram-token`, then push the machine") - } - return token, nil -} - -// Send posts a message and answers its message id. -func (t *Telegram) Send(m Message) (string, error) { - text := m.Text() - token, chat, err := t.ready() - if err != nil { - return "", err - } - var out struct { - MessageID int64 `json:"message_id"` - } - if err := t.call(token, "sendMessage", map[string]any{ - "chat_id": chat, "text": text, "disable_web_page_preview": true, - }, &out); err != nil { - return "", err - } - return fmt.Sprint(out.MessageID), nil -} - -// Edit replaces the text of a message sent before: how a cleared condition is said (to-be 45 §5). -func (t *Telegram) Edit(id string, m Message) error { - text := m.Text() - token, chat, err := t.ready() - if err != nil { - return err - } - return t.call(token, "editMessageText", map[string]any{ - "chat_id": chat, "message_id": json.Number(id), "text": text, "disable_web_page_preview": true, - }, nil) -} - -// CanEdit: Telegram edits a message in place. -func (t *Telegram) CanEdit() bool { return true } - -// SilentEdit: an edited Telegram message notifies nobody. -func (t *Telegram) SilentEdit() bool { return true } - -// Who asks the bot API who it is: the check that the token works, with no message sent. -func (t *Telegram) Who() (string, error) { - token, _, err := t.ready() - if err != nil { - return "", err - } - var me struct { - Username string `json:"username"` - } - if err := t.call(token, "getMe", map[string]any{}, &me); err != nil { - return "", err - } - return me.Username, nil -} - -func (t *Telegram) call(token, method string, body map[string]any, into any) error { - raw, _ := json.Marshal(body) - req, err := http.NewRequest(http.MethodPost, TelegramAPI+"/bot"+token+"/"+method, bytes.NewReader(raw)) - if err != nil { - return errors.New("telegram " + method + ": the request could not be made") - } - req.Header.Set("Content-Type", "application/json") - resp, err := t.Client.Do(req) - if err != nil { - return fmt.Errorf("telegram %s: %s", method, plainError(err)) - } - defer resp.Body.Close() - var answer struct { - OK bool `json:"ok"` - ErrorCode int `json:"error_code"` - Description string `json:"description"` - Result json.RawMessage `json:"result"` - } - if err := json.NewDecoder(resp.Body).Decode(&answer); err != nil { - return fmt.Errorf("telegram %s: HTTP %d with an answer that is not the bot API's", method, resp.StatusCode) - } - if !answer.OK { - d := strings.ReplaceAll(answer.Description, token, "") - return fmt.Errorf("telegram %s refused: %d %s", method, answer.ErrorCode, d) - } - if into != nil && len(answer.Result) > 0 { - _ = json.Unmarshal(answer.Result, into) - } - return nil -} - -// plainError is an error in words, without the URL a transport error carries. -func plainError(err error) string { - var ue *url.Error - if errors.As(err, &ue) { - err = ue.Err - } - var ne net.Error - switch { - case errors.As(err, &ne) && ne.Timeout(): - return "no answer in time" - case errors.Is(err, os.ErrPermission): - return "permission denied" - } - var dns *net.DNSError - if errors.As(err, &dns) { - return "the bot API's name does not resolve" - } - var op *net.OpError - if errors.As(err, &op) { - return "cannot connect (" + op.Op + ")" - } - s := err.Error() - if strings.Contains(s, "/bot") || strings.Contains(s, "://") { - return "the request failed" - } - return s -} diff --git a/modules/messenger/cmd/messenger/words_test.go b/modules/messenger/cmd/messenger/words_test.go index 389013ad..878d911a 100644 --- a/modules/messenger/cmd/messenger/words_test.go +++ b/modules/messenger/cmd/messenger/words_test.go @@ -105,8 +105,7 @@ var examples = []example{{ body: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, mnt-recalbox " + "(a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh does not repair " + "them.\nSince 12:40.", - offers: "Silence for a week, Details", choose: "Silence for a week", - asked: `seat:mesh-controller.conditions {"cause":"operator-answer","for":"7d","silence":"machine.shanks.units","why":"chosen on the desktop notification \"3 failed services on shanks\" on g14 (a desk click: whoever was at the operator's session)"}`, + offers: "Details", choose: "Details", cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute, }, { name: "a healer wanted", @@ -213,21 +212,12 @@ func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *te tok = a.Token } } - outcome, err := h.Chosen("desktop", "g14", tok, "event-1") - var done Message - if e.choose == DetailsLabel { - done = dt.sends[len(dt.sends)-1] - if err != nil || outcome != "shown" || len(asked) != 0 || done.Title != "Details: "+e.headline { - t.Fatalf("details: %q %v %v %q", outcome, err, asked, done.Title) - } - } else { - if err != nil || outcome != "done" || len(asked) != 1 || asked[0] != e.asked { - t.Fatalf("chose %s: %q %v\nasked %v\nwant %s", e.choose, outcome, err, asked, e.asked) - } - done = dt.edits["g14=1"] - if done.Title != e.choose+": done. "+e.headline { - t.Errorf("the answer's outcome: %q", done.Title) - } + // The router performs nothing: Details is shown where it was chosen, and the condition's own actions + // are never called from here (novox/hq ADR 0259) — the controller asks for them. + outcome, err := h.Chosen("desktop", choiceOf(tok, "g14", c.now())) + done := dt.sends[len(dt.sends)-1] + if err != nil || outcome != "shown" || len(asked) != 0 || done.Title != "Details: "+e.headline { + t.Fatalf("details: %q %v %v %q", outcome, err, asked, done.Title) } c.pass(e.openFor - BurstWindow) cleared, err := DecodeCondition(EventCleared, e.event(c.now(), true)) @@ -280,7 +270,7 @@ func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) { if details.Label != DetailsLabel { t.Fatalf("%+v", m.Actions) } - if _, err := h.Chosen("desktop", "g14", details.Token, ""); err != nil { + if _, err := h.Chosen("desktop", choiceOf(details.Token, "g14", c.now())); err != nil { t.Fatal(err) } shown := dt.sends[len(dt.sends)-1] @@ -288,7 +278,7 @@ func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) { !strings.Contains(shown.Body, "Since: 8 Oct 10:40") { t.Errorf("details: %q / %q", shown.Title, shown.Body) } - if _, err := h.Chosen("desktop", "g14", "not-a-token", ""); err == nil { + if _, err := h.Chosen("desktop", choiceOf("not-a-token", "g14", c.now())); err == nil { t.Error("an unknown answer was taken") } } diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index f6a1541c..9095a3a7 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -2,4 +2,4 @@ module messenger go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.7 +require git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 diff --git a/modules/messenger/go.sum b/modules/messenger/go.sum index b474419e..f4996b6c 100644 --- a/modules/messenger/go.sum +++ b/modules/messenger/go.sum @@ -1,2 +1,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008143736-3837f86eb6d4 h1:tB+O9kkF4cDw8By2DLQTZDRAjp5l3DltS52ypgOqLkM= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008143736-3837f86eb6d4/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008144136-2f2840488818 h1:o0yf4yyH8VfTe9FE963vSGEeMR662OXf38puZzNF/ug= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008144136-2f2840488818/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 h1:JT7xM1bnLNInW7/oImV2OlXTrcQ4/GSM0Y8tAb+AhmY= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 7801db20..bf189cec 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -9,7 +9,47 @@ "serves": [ "open", "history", - "notify" + "notify", + "asks" + ], + "accepts": [ + "ask", + "cancel" + ], + "emits": [ + "decided" + ], + "by-caller": [ + "ask", + "cancel", + "decided" + ], + "records": [ + "asks" + ] + }, + { + "name": "channel", + "scope": "mesh", + "kinded": true, + "accepts": [ + "show", + "edit", + "send" + ] + }, + { + "name": "intake", + "scope": "mesh", + "kinded": true, + "emits": [ + "choice", + "link", + "failed", + "standing" + ], + "proofs": [ + "code" ] } ], @@ -20,36 +60,45 @@ "serves": [ "open", "history", - "notify" + "notify", + "asks" ] } ], + "uses": [ + "channel" + ], "consumes": [ "mesh-controller.condition-raised", "mesh-controller.condition-changed", "mesh-controller.condition-cleared", - "dunst.action-chosen", - "dunst.started" + "intake.choice", + "intake.link", + "intake.failed", + "intake.standing" ], "emits": [ "refused" ], "invokes": [ - "seat:node-notifier.send", "seat:mesh-controller.conditions" ], "state": [ "open", - "sent" + "sent", + { + "name": "asks", + "ttl-seconds": 2592000 + }, + "identities" ], - "own-secrets": { - "telegram-token": "${dir:state}/telegram-token" - }, "tools": [ "messenger_status", "messenger_recent", "messenger_test", - "messenger_check" + "messenger_check", + "messenger_identities", + "messenger_unlink" ], "resources": [ { @@ -64,7 +113,7 @@ "path": "${dir:state}/settings.json", "mode": "0600", "merge": "json", - "content": "{\n \"telegram-chat-id\": \"\",\n \"desktop-machines\": [],\n \"time-zone\": \"\"\n}\n" + "content": "{\n \"time-zone\": \"\"\n}\n" } ], "build": { @@ -80,8 +129,7 @@ "messenger" ], "env": { - "MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json", - "MESH_MESSENGER_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token" + "MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json" } } ]