diff --git a/modules/desk-channel/README.md b/modules/desk-channel/README.md new file mode 100644 index 00000000..d92f5139 --- /dev/null +++ b/modules/desk-channel/README.md @@ -0,0 +1,45 @@ +# desk-channel + +The desk as a channel of the operator's conversation: kind `desktop` of the `channel` and `intake` benches the +router (`messenger`) declares (novox/hq ADR 0258, ADR 0259). It shows the router's messages on the notifier of +each machine the operator sits at (the `node-notifier` seat), and says which button was chosen there. + +- **It proves nothing about who clicked.** Any process of the operator's account can choose a notification's + action (`dunstctl action`), so its claim declares no `verified-sender`: the router offers here only + acknowledgements (Silence for a week, Details) and says in the words where an approval can be given. +- **An answer is taken only on a machine its message was shown on**, and once; a refused click is said where it + was made. A message with buttons stays until it is dismissed. +- **A notifier that started again** (`dunst.started`) is shown again every open message with buttons it showed. +- A message no machine took is said to the router as failed; the router tries it again. + +## What the operator gives + +The machines to show on, as a setting: `settings` for `desk-channel` with +`{"desktop-machines": ["", …]}`. Until then it says it cannot send, and the +router sends warnings to the channel that reaches the operator away. + +## Where it runs + +Held once for the mesh, on the same machine as the router, which is where it is assigned. **It runs as the +account `desk-channel`, in a runtime of its own, on a bus account of its own** (`runs-as`), as telegram does: +what it shows and the answers it says are out of reach of the operator's account, which agents run as. It asks +each machine's notifier through the mesh: nothing new runs on the machines. + +## A link's code (the review of 2026-10-09) + +- **It is shown only where it is seen.** Each machine's notifier says whether it is paused (`dunst.paused`, at + least every two minutes). A message the router marks secret is shown only on a named machine heard unpaused in + the last five minutes; while there is none, it is said to the router as failed and the desk says it cannot + send, so the router starts no link. A code held by a paused notifier would appear later, to whoever is at the + screen then. +- **It is kept nowhere.** Not in this module's state, and sent `secret` to the notifier, which sends it + transient in the category `mesh.secret`: dunst's rules keep it out of its history, and + `node-notifier.history`, which every caller of the mesh's verbs may read, leaves it out. +- **What remains**: while it is on screen, a program of the operator's account on that X display can read it + (X11 isolates no client from another). It is shown for as long as the router's code lives. + +## Tools + +| tool | does | +|---|---| +| `desk_channel_status` | whether it can show messages, the machines and each one's last answer, and its rules | diff --git a/modules/desk-channel/cmd/desk-channel/desk.go b/modules/desk-channel/cmd/desk-channel/desk.go new file mode 100644 index 00000000..9c90ece1 --- /dev/null +++ b/modules/desk-channel/cmd/desk-channel/desk.go @@ -0,0 +1,441 @@ +package main + +// The desk: kind `desktop` of the `channel` and `intake` benches (novox/hq ADR 0258, ADR 0259). It shows the +// router's messages on the notifier of each machine the operator sits at (the `node-notifier` seat, dunst) +// and says which button was chosen there. It is a channel like any other: the router judges every answer. +// +// - **It proves nothing about who clicked.** Any process of the operator's account can choose a +// notification's action, so its claim declares no `verified-sender`: the router offers here only +// acknowledgements, and says where an approval can be given. +// - **An answer is bound to the machines it was shown on**: a click on another machine is refused here, and +// said there. A message with buttons stays until it is dismissed. +// - **A notifier that started again lost the answers it waited for**: every open message with buttons shown +// on that machine is shown there again. +// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its +// status. A message no machine took is said to the router as failed, and tried again by it. + +import ( + "encoding/json" + "errors" + "fmt" + "html" + "sort" + "strconv" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// Kind is this channel's kind on both benches. +const Kind = "desktop" + +// Account is the account this module runs as, in a runtime of its own (runs-as). +const Account = "desk-channel" + +// PausedFresh is how old a notifier's word on whether it is paused may be: it says it every two minutes, so +// two missed is a notifier stopped or a machine away. +const PausedFresh = 5 * time.Minute + +// pauseSeen is what a machine's notifier last said of its pause, and when. +type pauseSeen struct { + paused bool + at time.Time +} + +// StandingEvery is how often the channel says whether it can send, at least. +const StandingEvery = 5 * time.Minute + +// Shown is one of the router's messages as the desk shows it: the message, and its notification on each +// machine. +type Shown struct { + Message asks.Message `json:"message"` + On map[string]int `json:"on"` + Sent time.Time `json:"sent"` +} + +// Store keeps what is shown, by the router's handle. +type Store interface { + Get(handle string) (*Shown, error) + Put(handle string, s Shown) error + Handles() ([]string, error) +} + +// Desk is the channel's whole working. +type Desk struct { + Machines func() []string + // Notify calls one machine's notifier: `seat:node-notifier.send@`. + Notify func(machine string, args map[string]any) (json.RawMessage, error) + Store Store + Publish func(subject string, body any, id string) error + Now func() time.Time + Logf func(string, ...any) + + mu sync.Mutex + last map[string]string // machine -> its last error, "" after a success + clickedOn map[string]string // a choice's id -> the machine it was made on, for the router's reply + said asks.Standing + saidAt time.Time + acted map[string]bool + pause map[string]pauseSeen +} + +func (d *Desk) init() { + d.mu.Lock() + defer d.mu.Unlock() + if d.last == nil { + d.last, d.clickedOn, d.acted = map[string]string{}, map[string]string{}, map[string]bool{} + } + if d.pause == nil { + d.pause = map[string]pauseSeen{} + } + if d.Now == nil { + d.Now = time.Now + } + if d.Logf == nil { + d.Logf = func(string, ...any) {} + } +} + +func (d *Desk) note(machine string, err error) { + d.mu.Lock() + defer d.mu.Unlock() + if err != nil { + d.last[machine] = err.Error() + } else { + d.last[machine] = "" + } +} + +// Take does one piece of the desk's work. It returns an error only to have the work offered again. +func (d *Desk) Take(subject string, body json.RawMessage) error { + d.init() + parts := strings.Split(subject, ".") + if len(parts) != 6 || parts[5] != Kind { + d.Logf("[desk-channel] work on %s is not this kind's; taken and dropped", subject) + return nil + } + var m asks.Message + if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" { + d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject) + return nil + } + machines := d.Machines() + if m.Secret { + // Shown only where the notifier said lately it is not paused (the review of 2026-10-09): one held back + // would appear later, to whoever is at the screen then. + seeing, why := d.seeing() + if len(seeing) == 0 { + f := asks.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()} + return d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)) + } + machines = seeing + } + if m.Reply != "" && !m.Secret { + // An answer to a click is shown where the click was made. + d.mu.Lock() + on, ok := d.clickedOn[m.Reply] + d.mu.Unlock() + if ok { + machines = []string{on} + } + } + var before *Shown + if parts[4] == asks.Edit { + var err error + if before, err = d.Store.Get(m.Handle); err != nil { + return err + } + } + s := Shown{Message: m, On: map[string]int{}, Sent: d.Now()} + var failed []string + for _, machine := range machines { + replace := 0 + if before != nil { + replace = before.On[machine] + if replace == 0 { + continue // an edit goes where the message was shown, and nowhere new + } + } + id, err := d.show(machine, m, replace) + if err != nil { + failed = append(failed, machine+": "+err.Error()) + continue + } + s.On[machine] = id + } + if len(s.On) == 0 { + why := "no machine showed it" + if len(failed) > 0 { + why += " — " + strings.Join(failed, "; ") + } + if len(machines) == 0 { + why = "no machine to show it on: the setting desktop-machines names none" + } + f := asks.Failed{Handle: m.Handle, Why: why, At: d.Now()} + if err := d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil { + return err + } + return nil + } + if m.Secret { + // Shown once and kept nowhere of this module's (a link's code): no edit or re-showing reaches it. + return nil + } + if err := d.Store.Put(m.Handle, s); err != nil { + d.Logf("[desk-channel] shown, and what was shown could not be kept: an edit of it will not reach it: %v", err) + } + return nil +} + +// show puts one message on one machine's notifier, in place of `replace` when it names one. +func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) { + urgency := "normal" + switch { + case m.Urgent: + urgency = "critical" + case m.Silent: + urgency = "low" + } + args := map[string]any{"summary": m.Title, "body": html.EscapeString(m.Body), "urgency": urgency, "app_name": "mesh"} + if replace != 0 { + args["replace_id"] = replace + } + if m.Secret { + // Transient and secret: the notifier keeps it out of the history every caller of the verbs reads. + args["secret"] = true + args["transient"] = true + } + if len(m.Buttons) > 0 { + var actions []map[string]string + for _, b := range m.Buttons { + actions = append(actions, map[string]string{"token": b.Ticket, "label": b.Label}) + } + // Answers stay until the operator dismisses them: a warning's answers must not die in ten seconds. + args["expire_ms"] = 0 + args["actions"] = actions + } + raw, err := d.Notify(machine, args) + if err == nil { + var id int + if id, err = notificationID(raw); err == nil { + d.note(machine, nil) + return id, nil + } + } + d.note(machine, err) + return 0, err +} + +// Chosen takes a button chosen on a machine's notifier (dunst's `action-chosen`): said to the router as this +// kind's choice when the message it belongs to was shown on that machine; refused there otherwise. The +// machine is the event's own node, said by the runtime; the same event is acted on once. +func (d *Desk) Chosen(event, machine, ticket string) { + d.init() + d.mu.Lock() + done := d.acted[event] + if event != "" { + d.acted[event] = true + } + d.mu.Unlock() + if done { + return + } + handle, shown := d.find(ticket) + if shown == nil { + _, _ = d.show(machine, asks.Message{Title: "That answer was not taken", + Body: "It belongs to no message shown here any more. Nothing was done.", Silent: true}, 0) + return + } + if _, here := shown.On[machine]; !here { + _, _ = d.show(machine, asks.Message{Title: "That answer was not taken", + Body: "It was not shown on " + machine + ". Nothing was done.", Silent: true}, 0) + return + } + id := "desk-" + event + if event == "" { + id = "desk-" + ticket + "-" + strconv.FormatInt(d.Now().UnixNano(), 10) + } + d.mu.Lock() + if len(d.clickedOn) > 500 { + d.clickedOn = map[string]string{} + } + d.clickedOn[id] = machine + d.mu.Unlock() + c := asks.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(), + Sender: asks.Sender{Identity: machine, Display: machine, Authenticated: false}} + if err := d.Publish(asks.IntakeSubject(asks.Chosen, Kind), c, id); err != nil { + d.Logf("[desk-channel] a choice could not be said to the router: %v", err) + _, _ = d.show(machine, asks.Message{Title: "That answer could not be taken now", + Body: "The mesh did not take it. Try again in a minute.", Silent: true}, 0) + } +} + +func (d *Desk) find(ticket string) (string, *Shown) { + handles, err := d.Store.Handles() + if err != nil { + return "", nil + } + for _, h := range handles { + s, err := d.Store.Get(h) + if err != nil || s == nil { + continue + } + for _, b := range s.Message.Buttons { + if b.Ticket == ticket { + return h, s + } + } + } + return "", nil +} + +// Started shows again, on a machine whose notifier started again, every message with buttons shown there. +func (d *Desk) Started(machine string) int { + d.init() + handles, err := d.Store.Handles() + if err != nil { + return 0 + } + sort.Strings(handles) + n := 0 + for _, h := range handles { + s, err := d.Store.Get(h) + if err != nil || s == nil || len(s.Message.Buttons) == 0 { + continue + } + if _, here := s.On[machine]; !here { + continue + } + id, err := d.show(machine, s.Message, 0) + if err != nil { + continue + } + s.On[machine] = id + _ = d.Store.Put(h, *s) + n++ + } + if n > 0 { + d.Logf("[desk-channel] the notifier on %s started again: %d message(s) with answers shown there again", machine, n) + } + return n +} + +// Paused takes a notifier's word on whether it is paused (dunst's event `paused`), from the event's own node. +func (d *Desk) Paused(machine string, paused bool) { + d.init() + d.mu.Lock() + defer d.mu.Unlock() + d.pause[machine] = pauseSeen{paused: paused, at: d.Now()} +} + +// seeing is the named machines whose notifier said lately that it is not paused, and why none is when none is. +func (d *Desk) seeing() ([]string, string) { + machines := d.Machines() + if len(machines) == 0 { + return nil, "no machine to show it on: the setting desktop-machines names none" + } + now := d.Now() + d.mu.Lock() + defer d.mu.Unlock() + var out, not []string + for _, m := range machines { + p, heard := d.pause[m] + switch { + case !heard || now.Sub(p.at) > PausedFresh: + not = append(not, m+" (its notifier not heard from lately)") + case p.paused: + not = append(not, m+" (notifications paused)") + default: + out = append(out, m) + } + } + if len(out) == 0 { + return nil, "notifications are paused on the desk, or its notifier was not heard from lately, on every " + + "machine named: " + strings.Join(not, ", ") + } + return out, "" +} + +// Standing is whether the desk can send: a named machine whose notifier said lately that it is not paused +// (the review of 2026-10-09). The router shows no link's code while the desk is not ready. +func (d *Desk) Standing() asks.Standing { + d.init() + s := asks.Standing{At: d.Now()} + if _, why := d.seeing(); why != "" { + s.Why = why + return s + } + s.Ready = true + return s +} + +// SayStanding says the standing when it changed, or every StandingEvery. +func (d *Desk) SayStanding() { + s := d.Standing() + d.mu.Lock() + same := s.Ready == d.said.Ready && s.Why == d.said.Why && s.At.Sub(d.saidAt) < StandingEvery + d.mu.Unlock() + if same { + return + } + if err := d.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil { + d.Logf("[desk-channel] could not say whether it can send: %v", err) + return + } + d.mu.Lock() + d.said, d.saidAt = s, s.At + d.mu.Unlock() +} + +// Status is the desk's account of itself. +func (d *Desk) Status() map[string]any { + s := d.Standing() + d.mu.Lock() + defer d.mu.Unlock() + last := map[string]string{} + for k, v := range d.last { + if v == "" { + v = "took the last message" + } + last[k] = v + } + out := map[string]any{"kind": Kind, "can_send": s.Ready, "machines": d.Machines(), "machines_last": last, + "rules": []string{ + "it shows the router's messages on the notifier of each machine named in desktop-machines, and says which button was chosen there; the router judges every answer (novox/hq ADR 0259)", + "it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only", + "an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed", + "a notifier that started again is shown again every open message with buttons it showed", + "it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history", + }} + if s.Why != "" { + out["why_not"] = s.Why + } + return out +} + +// notificationID reads the notifier's answer, {"id": N}, bare or in a tool reply's text. +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 + } + } + return 0, fmt.Errorf("the notifier answered no id: %.80s", string(raw)) +} diff --git a/modules/desk-channel/cmd/desk-channel/desk_test.go b/modules/desk-channel/cmd/desk-channel/desk_test.go new file mode 100644 index 00000000..6a8caa91 --- /dev/null +++ b/modules/desk-channel/cmd/desk-channel/desk_test.go @@ -0,0 +1,261 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +type memShown map[string]Shown + +func (m memShown) Get(h string) (*Shown, error) { + s, ok := m[h] + if !ok { + return nil, nil + } + return &s, nil +} +func (m memShown) Put(h string, s Shown) error { m[h] = s; return nil } +func (m memShown) Handles() ([]string, error) { + var out []string + for h := range m { + out = append(out, h) + } + return out, nil +} + +type notified struct { + machine string + args map[string]any +} + +func newDesk(t *testing.T, machines ...string) (*Desk, *[]notified, map[string][]json.RawMessage, memShown) { + t.Helper() + var shown []notified + said := map[string][]json.RawMessage{} + store := memShown{} + n := 0 + d := &Desk{Machines: func() []string { return machines }, Store: store, Logf: t.Logf, + Now: func() time.Time { return time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC) }, + Notify: func(machine string, args map[string]any) (json.RawMessage, error) { + if machine == "away-machine" { + return nil, errors.New("no session answers") + } + n++ + shown = append(shown, notified{machine, args}) + raw, _ := json.Marshal(map[string]int{"id": n}) + return raw, nil + }, + Publish: func(subject string, body any, id string) error { + raw, _ := json.Marshal(body) + said[subject] = append(said[subject], raw) + return nil + }} + d.init() + return d, &shown, said, store +} + +func take(t *testing.T, d *Desk, verb string, m asks.Message) { + t.Helper() + raw, _ := json.Marshal(m) + if err := d.Take(asks.ChannelSubject(verb, Kind), raw); err != nil { + t.Fatal(err) + } +} + +// novox/hq ADR 0259: the desk shows the router's message with its tickets as the notifier's actions, and +// says a click as a choice the router judges — only on a machine the message was shown on. +func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) { + d, shown, said, _ := newDesk(t, "laptop", "away-machine") + take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: them.", + Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}}) + if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["expire_ms"] != 0 || + (*shown)[0].args["body"] != "Needs you: <mend> them." { + t.Fatalf("shown %+v", *shown) + } + d.Chosen("e1", "laptop", "t1") + d.Chosen("e1", "laptop", "t1") // a redelivery + got := said[asks.IntakeSubject(asks.Chosen, Kind)] + var c asks.Choice + if len(got) != 1 || json.Unmarshal(got[0], &c) != nil || c.Ticket != "t1" || c.Handle != "m1" || + c.Sender.Identity != "laptop" || c.Sender.Authenticated { + t.Fatalf("said %s", got) + } + // Elsewhere it is refused, there, and nothing is said to the router. + d.Chosen("e2", "workstation", "t1") + d.Chosen("e3", "laptop", "forged") + if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 { + t.Error("a click not shown there was said") + } + if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "That answer was not taken" { + t.Errorf("the refusal was not shown where it was chosen: %+v", last) + } + // The router's reply to the click is shown on the machine it was made on. + take(t, d, asks.Send, asks.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID}) + if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "Details: 3 failed services" { + t.Errorf("%+v", last) + } +} + +func TestAnEditReplacesTheNotificationAndANotifierThatStartedAgainIsShownItsQuestions(t *testing.T) { + d, shown, _, store := newDesk(t, "laptop") + take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}}) + take(t, d, asks.Edit, asks.Message{Handle: "m1", Title: "Silence for a week: chosen"}) + if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil { + t.Errorf("the edit: %+v", last.args) + } + take(t, d, asks.Show, asks.Message{Handle: "m2", Title: "q2", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t2"}}}) + before := len(*shown) + if n := d.Started("laptop"); n != 1 || len(*shown) != before+1 { + t.Errorf("shown again: %d", n) + } + if store["m2"].On["laptop"] != len(*shown) { + t.Errorf("the new notification is not kept: %+v", store["m2"]) + } +} + +func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) { + d, _, said, _ := newDesk(t, "away-machine") + take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "x"}) + if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") || + strings.Contains(string(f[0]), `"permanent":true`) { + t.Errorf("%s", f) + } + none, _, _, _ := newDesk(t) + if s := none.Standing(); s.Ready || !strings.Contains(s.Why, "desktop-machines names none") { + t.Errorf("%+v", s) + } +} + +// It claims the desk's kind, and promises nothing that would let it approve. +func TestItPromisesNoVerifiedSender(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Claims []struct { + Name, Kind string + Capabilities []string + } + Consumes []string + } + _ = json.Unmarshal(raw, &m) + if len(m.Claims) != 2 || m.Claims[0].Kind != Kind || m.Claims[1].Kind != Kind { + t.Fatalf("%+v", m.Claims) + } + for _, c := range m.Claims[0].Capabilities { + if c == "verified-sender" || c == "reaches-away" { + t.Errorf("the desk promises %s", c) + } + } + if strings.Join(m.Consumes, ",") != AnswerEvent+","+StartedEvent+","+PausedEvent { + t.Errorf("consumes %v", m.Consumes) + } +} + +// A message the router marks secret (a link's code) is shown and kept nowhere of this module's. +func TestASecretMessageIsShownAndNotKept(t *testing.T) { + d, shown, _, store := newDesk(t, "laptop") + d.Paused("laptop", false) + take(t, d, asks.Send, asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}) + if len(*shown) != 1 { + t.Fatal("not shown") + } + if _, kept := store["code"]; kept { + t.Error("the code was kept") + } +} + +// The review of 2026-10-09 (H4, C4): a link's code is shown only on a machine whose notifier said lately that +// it is not paused — a code held back by a paused notifier appears later, to whoever is at the screen then — +// and is sent secret, so the notifier keeps it out of the history every caller of the verbs reads. While no +// named machine is heard unpaused, the desk says it cannot send. +func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testing.T) { + d, shown, said, store := newDesk(t, "laptop", "desk2") + now := d.Now() + if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "not heard") { + t.Fatalf("ready before any notifier said whether it is paused: %+v", s) + } + d.Paused("laptop", false) + if s := d.Standing(); !s.Ready { + t.Fatalf("not ready with laptop heard unpaused: %+v", s) + } + code := asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true} + take(t, d, asks.Send, code) + if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true { + t.Fatalf("the code was shown as %+v", *shown) + } + if _, kept := store["code"]; kept { + t.Error("the code was kept") + } + // Paused: not shown anywhere, said as failed, and the desk is not ready. + d.Paused("laptop", true) + take(t, d, asks.Send, code) + if len(*shown) != 1 { + t.Fatalf("the code was shown on a paused notifier: %+v", *shown) + } + if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") { + t.Errorf("not said as failed: %s", f) + } + if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "paused") { + t.Errorf("ready while paused: %+v", s) + } + // Heard unpaused, but longer ago than PausedFresh: not known now. + d.Paused("laptop", false) + d.Now = func() time.Time { return now.Add(PausedFresh + time.Second) } + if s := d.Standing(); s.Ready { + t.Errorf("ready on a word older than %s: %+v", PausedFresh, s) + } + // A machine not named is never one to show on, whatever it says. + d.Paused("stranger", false) + if s := d.Standing(); s.Ready { + t.Error("a machine not named made the desk ready") + } + // A message that is not secret still goes to every named machine, paused or not. + take(t, d, asks.Send, asks.Message{Handle: "m9", Title: "x"}) + if len(*shown) != 3 { + t.Errorf("an ordinary message was held back: %+v", *shown) + } +} + +// It runs as its own account (the review of 2026-10-09, H4): what it shows and the answers it says are its +// own, out of every agent's reach, as telegram's are. +func TestItRunsAsItsOwnAccount(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + RunsAs string `json:"runs-as"` + Own map[string]string `json:"own-secrets"` + Owner string `json:"secrets-owner"` + Resources []map[string]any + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m.RunsAs != Account || m.Owner != Account || m.Own["broker"] == "" { + t.Fatalf("runs as %q, owner %q, secrets %v", m.RunsAs, m.Owner, m.Own) + } + var account, state, settings bool + for _, r := range m.Resources { + switch r["id"] { + case "account": + account = r["type"] == "user" && r["name"] == Account && r["shell"] == "/usr/bin/nologin" + case "state": + state = r["owner"] == Account && r["mode"] == "0700" + case "settings": + settings = r["owner"] == Account && r["mode"] == "0600" + } + } + if !account || !state || !settings { + t.Errorf("the account %v, its state %v, its settings %v", account, state, settings) + } +} diff --git a/modules/desk-channel/cmd/desk-channel/main.go b/modules/desk-channel/cmd/desk-channel/main.go new file mode 100644 index 00000000..6e9519b0 --- /dev/null +++ b/modules/desk-channel/cmd/desk-channel/main.go @@ -0,0 +1,144 @@ +// desk-channel: the desk as a channel of the operator's conversation, kind `desktop` of the `channel` and +// `intake` benches (novox/hq ADR 0258, ADR 0259). A Go bundle the node's runtime launches. stdout is the MCP +// channel; what this module says, it says on stderr. +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// Worker is the work queue of this kind, as the controller names it. +const Worker = "SEAT_CHANNEL_DESKTOP_worker" + +// The notifier's events, which the dunst holder of node-notifier emits as its module's own. +const ( + AnswerEvent = "dunst.action-chosen" + StartedEvent = "dunst.started" + PausedEvent = "dunst.paused" +) + +type busStore struct{} + +func (busStore) Get(handle string) (*Shown, error) { + e, err := stdio.State("shown").Get(handle) + if err != nil || e == nil { + return nil, err + } + var s Shown + return &s, json.Unmarshal(e.Value, &s) +} +func (busStore) Put(handle string, s Shown) error { + _, err := stdio.State("shown").Put(handle, s) + return err +} +func (busStore) Handles() ([]string, error) { return stdio.State("shown").Keys() } + +// machines reads the setting desktop-machines at each use, so a change takes effect without a restart. +func machines(path string) []string { + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + var s struct { + Machines []string `json:"desktop-machines"` + } + if json.Unmarshal(raw, &s) != nil { + return nil + } + var out []string + for _, m := range s.Machines { + if m = strings.TrimSpace(m); m != "" { + out = append(out, m) + } + } + return out +} + +func main() { + settings := os.Getenv("MESH_DESK_SETTINGS") + d := &Desk{ + Machines: func() []string { return machines(settings) }, + Notify: func(machine string, args map[string]any) (json.RawMessage, error) { + return stdio.Ask("seat:node-notifier.send@"+machine, args) + }, + Store: busStore{}, + Publish: func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) }, + Logf: logf, + } + d.init() + go run(d) + tools := []stdio.Tool{{Name: "desk_channel_status", + Description: "Whether the desk can show the operator's messages, and on which machines: the setting " + + "desktop-machines, each machine's last answer, and the rules it keeps.", + Run: func(map[string]any) (any, error) { return d.Status(), nil }}} + if err := stdio.Serve("", tools); err != nil { + logf("%v", err) + os.Exit(1) + } +} + +func run(d *Desk) { + time.Sleep(500 * time.Millisecond) // Serve first: the bus is reached through it + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + if err := stdio.SeatTake(Worker, func(w stdio.Work) error { return d.Take(w.Subject, w.Body) }); err == nil { + logf("[desk-channel] taking the router's messages for the desk") + return + } else { + logf("[desk-channel] not taking messages yet (%v); asking again in %s", err, wait) + } + time.Sleep(wait) + } + }() + go func() { + for { + d.SayStanding() + time.Sleep(time.Minute) + } + }() + handle := func(e stdio.Envelope) error { + var body struct { + Token string `json:"token"` + Machine string `json:"machine"` + Paused *bool `json:"paused"` + } + _ = json.Unmarshal(e.Body, &body) + // The machine is the event's own node, said by the runtime: the body's word is only a hint. + machine := e.Node + if machine == "" { + machine = body.Machine + } + switch e.Key { + case StartedEvent: + d.Started(machine) + case PausedEvent: + // Only from the event's own node: a word about another machine is no word at all. + if body.Paused != nil && e.Node != "" { + d.Paused(e.Node, *body.Paused) + d.SayStanding() + } + case AnswerEvent: + if body.Token != "" { + d.Chosen(e.Headers["x-event-id"], machine, body.Token) + } + } + return nil + } + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + if err := stdio.Subscribe("dunst.*", handle); err == nil { + logf("[desk-channel] listening for the answers chosen on the desk") + return + } else { + logf("[desk-channel] not hearing the desk's answers yet (%v); asking again in %s", err, wait) + } + time.Sleep(wait) + } +} diff --git a/modules/desk-channel/go.mod b/modules/desk-channel/go.mod new file mode 100644 index 00000000..d18c0d42 --- /dev/null +++ b/modules/desk-channel/go.mod @@ -0,0 +1,5 @@ +module deskchannel + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.10 diff --git a/modules/desk-channel/go.sum b/modules/desk-channel/go.sum new file mode 100644 index 00000000..8446865c --- /dev/null +++ b/modules/desk-channel/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o= +git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/desk-channel/module.json b/modules/desk-channel/module.json new file mode 100644 index 00000000..309efbb4 --- /dev/null +++ b/modules/desk-channel/module.json @@ -0,0 +1,91 @@ +{ + "module": "desk-channel", + "version": "1", + "slug": "desk", + "runs-as": "desk-channel", + "claims": [ + { + "name": "channel", + "scope": "mesh", + "kind": "desktop", + "capabilities": [ + "deliver", + "silent", + "loud", + "edit", + "private", + "choice", + "exact-render" + ] + }, + { + "name": "intake", + "scope": "mesh", + "kind": "desktop" + } + ], + "consumes": [ + "dunst.action-chosen", + "dunst.started", + "dunst.paused" + ], + "invokes": [ + "seat:node-notifier.send" + ], + "state": [ + { + "name": "shown", + "ttl-seconds": 2592000 + } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "desk-channel", + "tools": [ + "desk_channel_status" + ], + "resources": [ + { + "id": "account", + "type": "user", + "name": "desk-channel", + "shell": "/usr/bin/nologin", + "home": "/var/lib/desk-channel" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "desk-channel", + "place": "." + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "desk-channel", + "merge": "json", + "content": "{\n \"desktop-machines\": []\n}\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/desk-channel", + "binary": "desk-channel", + "loads": [ + "desk-channel" + ], + "env": { + "MESH_DESK_SETTINGS": "${dir:state}/settings.json" + } + } + ] + } +} diff --git a/modules/dunst/README.md b/modules/dunst/README.md index 53661357..6c288e2f 100644 --- a/modules/dunst/README.md +++ b/modules/dunst/README.md @@ -29,6 +29,16 @@ ends the waits it held; it says `started` {machine} once it serves, and the oper open answers there again (novox/hq ADR 0258). `notify-send` prints the id before it waits, also into a pipe (checked on the laptop on 2026-10-08: the id at once, and "Wait timeout expired" when it expired). +## What is shown once, and whether it would be seen + +- **A secret notification** (`send` with `secret`: a link's code) is sent transient and in the category + `mesh.secret`, whatever category was named. The dunstrc's rules keep it, and every transient one, out of + dunst's history (`history_ignore`), and `history` leaves out any that got there anyway, counting them as + `withheld`: the history is served to every caller of the mesh's verbs, agents among them. +- **Whether notifications are paused** is said as this module's event `paused` {paused, machine}: at start, + on every change, and every two minutes, read from dunst itself so a pause set by a key counts too. The desk + shows a link's code only on a machine it heard is not paused lately. + ## Tools Every tool goes over the account's session bus. None needs the screen, and each answers clearly when diff --git a/modules/dunst/cmd/dunst-tools/dunst.go b/modules/dunst/cmd/dunst-tools/dunst.go index a9991095..3ef707cc 100644 --- a/modules/dunst/cmd/dunst-tools/dunst.go +++ b/modules/dunst/cmd/dunst-tools/dunst.go @@ -28,8 +28,16 @@ type Notification struct { Category string ReplaceID int Actions []Choice + // Transient asks dunst to keep it out of its history; Secret marks something shown once (a link's code): + // transient, and in SecretCategory whatever category was named, which the dunstrc keeps out of the + // history and the history verb leaves out (the review of 2026-10-09). + Transient bool + Secret bool } +// SecretCategory is the category a secret notification is sent in, and the one the history never shows. +const SecretCategory = "mesh.secret" + func notificationOf(args map[string]any) (Notification, error) { var n Notification var err error @@ -70,10 +78,32 @@ func notificationOf(args map[string]any) (Notification, error) { if n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1); err != nil { return n, err } + if n.Transient, err = boolean(args, "transient"); err != nil { + return n, err + } + if n.Secret, err = boolean(args, "secret"); err != nil { + return n, err + } + if n.Secret { + n.Transient, n.Category = true, SecretCategory + } n.Actions, err = choicesOf(args) return n, err } +// boolean reads an optional true or false. +func boolean(args map[string]any, key string) (bool, error) { + v, present := args[key] + if !present || v == nil { + return false, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s is true or false", key) + } + return b, nil +} + // SendResult is what node-notifier.send answers. type SendResult struct { ID int `json:"id"` @@ -100,6 +130,9 @@ func Send(n Notification) (SendResult, error) { if n.Category != "" { args = append(args, "--category="+n.Category) } + if n.Transient { + args = append(args, "--transient") + } if n.ReplaceID > 0 { args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID)) } @@ -187,6 +220,8 @@ type Shown struct { type HistoryResult struct { Total int `json:"total"` Notifications []Shown `json:"notifications"` + // Withheld is how many secret notifications the history held and this left out. + Withheld int `json:"withheld,omitempty"` } // History is dunst's history, newest first. @@ -203,13 +238,18 @@ func parseHistory(raw string, nowMicros int64, limit int) (HistoryResult, error) if err != nil { return HistoryResult{}, err } - out := HistoryResult{Total: len(entries), Notifications: []Shown{}} + out := HistoryResult{Notifications: []Shown{}} type stamped struct { Shown at int64 } var all []stamped for _, e := range entries { + if str(e["category"]) == SecretCategory { + out.Withheld++ + continue + } + out.Total++ at := number(e["timestamp"]) all = append(all, stamped{Shown{ ID: int(number(e["id"])), AppName: str(e["appname"]), Summary: str(e["summary"]), Body: str(e["body"]), diff --git a/modules/dunst/cmd/dunst-tools/main.go b/modules/dunst/cmd/dunst-tools/main.go index dc493989..547e9cab 100644 --- a/modules/dunst/cmd/dunst-tools/main.go +++ b/modules/dunst/cmd/dunst-tools/main.go @@ -12,6 +12,7 @@ import ( func main() { go sayStarted() + go watchPaused() if err := stdio.Serve("", tools()); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -25,7 +26,8 @@ func tools() []stdio.Tool { Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " + "normal, critical), and optionally the sending application's name, an icon, how long it stays and " + "the answers it offers (actions: token and label each). Answers the notification's id; the answer " + - "the operator chooses is said later as the event action-chosen {id, token, machine}.", + "the operator chooses is said later as the event action-chosen {id, token, machine}. A secret " + + "or transient one is kept out of the history.", Input: map[string]any{ "type": "object", "properties": map[string]any{ @@ -37,6 +39,9 @@ func tools() []stdio.Tool { "expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"}, "category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"}, "replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"}, + "transient": map[string]any{"type": "boolean", "description": "keep it out of the history (default false)"}, + "secret": map[string]any{"type": "boolean", "description": "it carries something shown once, such as a " + + "link's code: transient, in the category " + SecretCategory + ", and never in the history (default false)"}, "actions": map[string]any{"type": "array", "description": "at most 4 answers the notification offers, " + "each {token, label}; the chosen token is said as the event action-chosen", "items": map[string]any{"type": "object", "properties": map[string]any{ @@ -55,7 +60,7 @@ func tools() []stdio.Tool { { Name: "node-notifier.history", Description: "The notifications the operator was shown, newest first: id, application, title, " + - "body, urgency and how long ago.", + "body, urgency and how long ago. A secret one (a link's code) is never shown; withheld counts them.", Input: map[string]any{ "limit": map[string]any{"type": "integer", "description": "at most this many (default 20, at most 200)"}, }, diff --git a/modules/dunst/cmd/dunst-tools/paused.go b/modules/dunst/cmd/dunst-tools/paused.go new file mode 100644 index 00000000..8d55ac29 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/paused.go @@ -0,0 +1,71 @@ +package main + +// Whether notifications are paused, said as this module's event `paused` {paused, machine} (the review of +// 2026-10-09, C4): at start, on every change, and again every PausedEvery. The desk shows a link's code only on +// a machine it heard is not paused lately, and says it cannot send one while none is: a code held back by a +// paused notifier would appear later, to whoever is then at the screen. The pause is read from dunst itself +// (`dunstctl is-paused`), so one set by a key or by hand is said like one set by dunst_pause. + +import ( + "fmt" + "os" + "strings" + "time" +) + +const ( + // PausedEvent is the event's name; PausedEvery how often it is said when nothing changed; PausedLook how + // often dunst is asked. + PausedEvent = "paused" + PausedEvery = 2 * time.Minute + PausedLook = 5 * time.Second +) + +// pauseWatch says the pause; its two reaches are seams a test replaces. +type pauseWatch struct { + isPaused func() (bool, error) + say func(paused bool) error + saidAt time.Time + said bool + known bool +} + +// step looks once, and says it when it changed or was said PausedEvery ago. Nothing is said while dunst +// cannot be asked: the desk then hears nothing, and takes the machine as not known. +func (w *pauseWatch) step(now time.Time) { + paused, err := w.isPaused() + if err != nil { + return + } + if w.known && paused == w.said && now.Sub(w.saidAt) < PausedEvery { + return + } + if w.say(paused) == nil { + w.known, w.said, w.saidAt = true, paused, now + } +} + +func isPaused() (bool, error) { + out, err := dunstctl("is-paused") + if err != nil { + return false, err + } + switch strings.TrimSpace(out) { + case "true": + return true, nil + case "false": + return false, nil + } + return false, fmt.Errorf("dunstctl is-paused answered %q", out) +} + +// watchPaused runs the watch for as long as the bundle runs. +func watchPaused() { + w := &pauseWatch{isPaused: isPaused, say: func(p bool) error { + return emit(PausedEvent, map[string]any{"paused": p, "machine": os.Getenv("MESH_NODE")}) + }} + for { + w.step(time.Now()) + time.Sleep(PausedLook) + } +} diff --git a/modules/dunst/cmd/dunst-tools/secret_test.go b/modules/dunst/cmd/dunst-tools/secret_test.go new file mode 100644 index 00000000..728e2169 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/secret_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// A notification carrying something shown once — a link's code — is sent transient and in the category +// mesh.secret, whatever category the caller names (the review of 2026-10-09, H4): the dunstrc's rules keep +// both out of dunst's history, and the history verb leaves out any that got there anyway, so no agent reads +// the code back through node-notifier.history. +func TestASecretNotificationIsTransientAndKeptOutOfTheHistory(t *testing.T) { + withBus(t) + bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + n, err := notificationOf(map[string]any{"summary": "Link code", "body": "123 456", "secret": true, "category": "email"}) + if err != nil { + t.Fatal(err) + } + if _, err := Send(n); err != nil { + t.Fatal(err) + } + asked, _ := os.ReadFile(filepath.Join(bin, "log")) + for _, want := range []string{"[--transient]", "[--category=" + SecretCategory + "]"} { + if !strings.Contains(string(asked), want) { + t.Errorf("notify-send was not asked %s: %s", want, asked) + } + } + if strings.Contains(string(asked), "--category=email") { + t.Error("a secret notification kept the caller's category") + } + if n, err := notificationOf(map[string]any{"summary": "x", "transient": true}); err != nil || !n.Transient || n.Secret { + t.Errorf("transient alone: %+v %v", n, err) + } + + // The history leaves a secret one out, and says how many it left out. + withSecret := strings.Replace(history, `"category":{"type":"s","data":"device"}`, + `"category":{"type":"s","data":"`+SecretCategory+`"}`, 1) + got, err := parseHistory(withSecret, 200_000_000, 20) + if err != nil { + t.Fatal(err) + } + if len(got.Notifications) != 1 || got.Notifications[0].ID != 7 || got.Total != 1 || got.Withheld != 1 { + t.Fatalf("the history showed a secret notification: %+v", got) + } + + // The decided file holds the rules that keep them out of dunst's own history. + rc, err := os.ReadFile(filepath.Join("..", "..", "files", "dunstrc")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"category = \"" + SecretCategory + "\"", "match_transient = yes", "history_ignore = yes"} { + if !strings.Contains(string(rc), want) { + t.Errorf("dunstrc does not say %q", want) + } + } +} + +// Whether notifications are paused is said as the event `paused` {paused, machine}: at once, on every change, +// and again every PausedEvery, so the desk knows a link's code would not be seen (C4); nothing is said while +// dunst cannot be asked. +func TestWhetherPausedIsSaidOnChangeAndEveryFewMinutes(t *testing.T) { + var said []bool + paused, fails := false, false + w := pauseWatch{ + isPaused: func() (bool, error) { + if fails { + return false, os.ErrNotExist + } + return paused, nil + }, + say: func(p bool) error { said = append(said, p); return nil }, + } + at := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) + w.step(at) + w.step(at.Add(5 * time.Second)) + paused = true + w.step(at.Add(10 * time.Second)) + w.step(at.Add(10*time.Second + PausedEvery)) + fails = true + w.step(at.Add(10*time.Second + 3*PausedEvery)) + if len(said) != 3 || said[0] || !said[1] || !said[2] { + t.Fatalf("said %v", said) + } +} diff --git a/modules/dunst/files/dunstrc b/modules/dunst/files/dunstrc index 705a1f42..727a12e1 100644 --- a/modules/dunst/files/dunstrc +++ b/modules/dunst/files/dunstrc @@ -92,3 +92,14 @@ foreground = "#ffffff" frame_color = "#ff0000" timeout = 0 + +# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's +# code, category mesh.secret) and any transient one leave no entry in the history, which +# node-notifier.history serves to every caller of the mesh's verbs. +[mesh-secret] + category = "mesh.secret" + history_ignore = yes + +[transient-history-ignore] + match_transient = yes + history_ignore = yes diff --git a/modules/dunst/module.json b/modules/dunst/module.json index 7c37c2ae..37c0adfa 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -66,7 +66,7 @@ "path": "${machine:account-home}/.config/dunst/dunstrc", "owner": "${machine:account}", "mode": "0644", - "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n" + "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n" }, { "id": "notifier", @@ -96,6 +96,7 @@ }, "emits": [ "action-chosen", - "started" + "started", + "paused" ] } diff --git a/modules/messenger/README.md b/modules/messenger/README.md index 0841debd..6ddf21d8 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -1,20 +1,71 @@ # 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, with the account's name and number, on a + channel whose words stay private (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. **The code is shown now or never**: + while the desk cannot show it (its notifications paused, nobody there), no link is begun and no code waits + in a queue. At most three links an hour per account, and ten per kind, counted in the router's state. Every + link and unlink is said on every channel. An account unlinked less than a day ago is not linked again by a + code; one account per kind is checked again as a link is made. +- **Unlinking is asked of the account that is linked** (`messenger_unlink` asks it): an approve question put + to that account alone. Its approval unlinks it, told to it first, by name, then everywhere. **A link within + a day of an unlink waits for the replaced account's approval**, asked of it by name. Each unlink or relink + question binds the account it is about, so its approval never acts on another account linked meanwhile. + **An account still in its first hour**, which cannot approve yet, is unlinked from the desk: when another + account sends `/start`, the desk is asked whether to unlink it. +- **When that account is lost with its phone**, root at the router's machine leaves a file in this module's + state directory — `unlink.`, or `allow-link.` for a link that waits, a regular file owned by + uid 0 — and the router does it within the minute, said on every channel with what was done, and every open + question of the router's own ends. A file not made by root is refused and said. +- **A channel proving its sender says only to the linked account**: none linked, it is not ready, and asks + for approval are not offered on it. **An approval rests on the controller's positive word**, asked of its + verb `root-free` for the router's machine and the channel's, live when an answer arrives (a word at most + 30 s old renders the buttons): only a machine it says is free carries an approval. A router that does not + know its machine, a controller that does not answer, a machine its answer leaves out, or a word on the + channel kinds older than two minutes all fail closed. +- **An ask that offers an approval is answered only where it could be approved**, whichever option is + chosen: the desk, which proves nothing of who clicked, is not offered it, and an acknowledgement from a + fresh link does not end it. An asker wanting an acknowledgement at the desk asks it alone. +- **Each ask's message says who asks** (as the bus names it), what each answer does, and what happens if + nobody answers. A warrant is published under a message id nobody can foresee. +- **The router runs as its own account `messenger`, on a bus account of its own**, never carried by the + machine's runtime (ADR 0259 §8): its state and its bus credential are that account's. ## What is sent, and when @@ -49,18 +100,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 +114,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 +133,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 +148,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..4c81b708 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,83 @@ 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) { + // The controller's root-free word is asked before the work is taken (the confirmation review of 2026-10-09): + // up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer, + // and judged with in it. + root := h.rootWordFor(kind) h.work.Lock() defer h.work.Unlock() h.mu.Lock() h.init() - if 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, root) + 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 +148,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 +190,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 +214,49 @@ 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" + } + } + desk := h.Bench != nil && h.Bench.roleOf(kind) == RoleDesk + h.mu.Unlock() + // A warning the desk could not show goes to the channel that reaches the operator away, as it did when the + // desk was the router's own (the correctness review of 2026-10-08). + if desk && r != nil && r.Cleared.IsZero() && r.Severity != Urgent && h.Away != nil && h.Away.Ready() == nil { + h.enqueue(h.Away.Name(), item{Key: r.Key, What: "raised", Severity: r.Severity, At: h.Now()}) + h.mu.Lock() + r.Pending = "" + h.mu.Unlock() + h.flush(h.Now(), false) + } + outcome := "failed: " + f.Why + if f.Permanent { + outcome = "failed for good: " + f.Why + } + h.record(Sent{At: h.Now(), Channel: kind, Key: keyOrNone(r), What: "delivery", Outcome: outcome}) + if r != nil { + h.persist(r) + } } -func (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..2a5eed39 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -3,297 +3,1031 @@ package main import ( "encoding/json" "fmt" + "os" + "path/filepath" "strings" + "sync" "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 { + mu sync.Mutex + asked map[string][]time.Time + asks map[string]AskRecord + revs map[string]uint64 + ids map[string]Identity + links map[string]PendingLink + unlinked map[string]Unlinked + 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{}, unlinked: map[string]Unlinked{}, asked: map[string][]time.Time{}} +} + +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) { + m.mu.Lock() + defer m.mu.Unlock() + 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) { + m.mu.Lock() + defer m.mu.Unlock() + 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) { + m.mu.Lock() + defer m.mu.Unlock() + 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) { + m.mu.Lock() + defer m.mu.Unlock() + var out []string + for k := range m.asks { + out = append(out, k) + } + return out, nil +} +func (m *memAsks) GetIdentity(k string) (*Identity, error) { + m.mu.Lock() + defer m.mu.Unlock() + id, ok := m.ids[k] + if !ok { + return nil, nil + } + return &id, nil +} +func (m *memAsks) PutIdentity(k string, id Identity) error { + m.mu.Lock() + defer m.mu.Unlock() + m.ids[k] = id + return nil +} +func (m *memAsks) DeleteIdentity(k string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.ids, k) + return nil +} +func (m *memAsks) IdentityKeys() ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []string + for k := range m.ids { + out = append(out, k) + } + return out, nil +} +func (m *memAsks) GetLink(kind string) (*PendingLink, error) { + m.mu.Lock() + defer m.mu.Unlock() + l, ok := m.links[kind] + if !ok { + return nil, nil + } + return &l, nil +} +func (m *memAsks) PutLink(kind string, l PendingLink) error { + m.mu.Lock() + defer m.mu.Unlock() + m.links[kind] = l + return nil +} +func (m *memAsks) DeleteLink(kind string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.links, kind) + return nil +} +func (m *memAsks) GetUnlinked(kind string) (*Unlinked, error) { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.unlinked[kind] + if !ok { + return nil, nil + } + return &u, nil +} +func (m *memAsks) PutUnlinked(kind string, u Unlinked) error { + m.mu.Lock() + defer m.mu.Unlock() + m.unlinked[kind] = u + return nil +} +func (m *memAsks) GetLinksAsked(key string) ([]time.Time, error) { + m.mu.Lock() + defer m.mu.Unlock() + return append([]time.Time(nil), m.asked[key]...), nil +} +func (m *memAsks) PutLinksAsked(key string, times []time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + m.asked[key] = append([]time.Time(nil), times...) + return nil +} + +// rootFreeOf is each test router's machines said not root-free, by why. +var rootFreeOf = map[*Holder]map[string]string{} + +// 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", Node: "anchor", Capabilities: []string{"deliver", "reaches-away", "choice", "edit", + "verified-sender", "exact-render"}}, + {Kind: "desktop", Module: "desk-channel", Node: "anchor", Capabilities: []string{"deliver", "choice", "edit", "exact-render", "private"}}, + }) + bench.To = func(kind string) string { + if ids := h.identitiesOf(kind); len(ids) > 0 { + return ids[0].Identity + } + return "" + } + 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.Node = "anchor" + // The controller's word on root, as its verb root-free answers it: free unless a test says otherwise. + notFree := map[string]string{} + rootFreeOf[h] = notFree + h.RootFree = func(machines []string) (map[string]RootVerdict, error) { + out := map[string]RootVerdict{} + for _, m := range machines { + why, no := notFree[m] + out[m] = RootVerdict{Machine: m, Free: !no, Why: why} + } + return out, nil + } + 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) { + // The controller's word on the kinds, read again as the router does every half minute. + h.Bench.SetKinds(h.Bench.Kinds()) + h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()}) + h.Bench.Stand("desktop", Standing{Ready: true, At: c.now()}) +} + +// bound is what an option of the held delivery's ask binds: the asker's act, digested. +func bound(verb string) string { + d, err := asks.ActDigest(asks.Act{"verb": "mesh-delivery." + verb, "id": "novox/hq@0555"}) + if err != nil { + panic(err) + } + return d +} + +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, Binds: bound("release")}, + {ID: "stop", Label: "Stop", Does: "the delivery ends", Level: asks.Approve, Binds: bound("stop")}, + {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, ", ") +} + +// ackAsk is an ask of acknowledgements alone: silencing the held delivery. +func ackAsk(c *clock, id string) asks.Ask { + a := heldAsk(c, id) + a.Options = a.Options[2:] + a.Explanation = "Needs you: silence it, or leave it. A delivery of hq has been held for 36 hours." + return a +} + +// An ask that offers an approval is answered only where it could be approved, whichever option is chosen (the +// review of 2026-10-09): the desk, which proves nothing of who clicked, is offered none of it. An ask of +// acknowledgements alone is offered on every kind that carries a choice. +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) != 0 { + 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) + } + if rec := store.asks["mesh-delivery.d-1"]; rec.State != StateOpen || len(rec.Tickets) != 3 { + t.Errorf("recorded %+v", rec) + } + b := ackAsk(c, "d-2") + b.Urgent = true + b.About = "other" + submit(t, h, "mesh-delivery", b) + settle(h, c) + if len(dt.sends) != 1 || labelsOf(dt.sends[0]) != "Silence for a week" { + t.Fatalf("the desk was not offered an acknowledgement: %+v", dt.sends) + } +} + +func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) { + h, tg, dt, c, _, w := newRouter(t) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + // Telegram says only to the account linked as the operator: none linked, it is not used at all. + if len(tg.sends) != 0 { + t.Errorf("an unlinked Telegram was sent %d messages", len(tg.sends)) + } + if err := h.Bench.Ready("telegram"); err == nil || !strings.Contains(err.Error(), "send /start to the bot") { + t.Errorf("an unlinked Telegram is ready: %v", err) + } + // The ask offers an approval no channel can carry: refused to its asker, in words. + if got := w.of("mesh-delivery"); len(got) != 1 || got[0].Outcome != asks.OutcomeRefused || + !strings.Contains(got[0].Words, "no channel can carry") { + t.Fatalf("not refused: %+v", got) + } + if len(dt.sends) != 0 { + t.Errorf("the desk was offered an ask with an approval: %+v", dt.sends) + } +} + +func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + asked := heldAsk(c, "d-1") + submit(t, h, "mesh-delivery", asked) + 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", asked); err != nil { + t.Errorf("the asker would not act on it: %v", err) + } + // It answers the ask as it was taken, and no other ask under that id (ADR 0259 §6). + if got[0].AskDigest != asked.Digest() { + t.Errorf("the warrant names the digest %s, not the ask's %s", got[0].AskDigest, asked.Digest()) + } + if _, err := got[0].For("mesh-delivery", heldAsk(c, "d-1")); err == nil { + t.Error("a warrant was taken for an ask under the same id with another expiry") + } + 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} { + 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 answer this question 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") + } + title := "That answer was not taken" + if tc.identity == "666" { + // Another account's answer is said to the operator, naming that account (the lab's proof found + // the plain refusal reached the operator and read as their own answer refused). + title = "Another account tried to answer for you" + } + if tc.kind == "telegram" && (len(tg.sends) != before+1 || tg.sends[len(tg.sends)-1].Title != title) { + t.Errorf("the refusal was not said on the channel: %+v", tg.sends[before:]) + } + if tc.identity == "666" && !strings.Contains(tg.sends[len(tg.sends)-1].Body, "number 666") { + t.Errorf("the notice does not name the other account: %q", tg.sends[len(tg.sends)-1].Body) + } + }) } } -// **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) + } + // An acknowledgement is taken at once from a fresh link, on an ask of acknowledgements alone. + h2, tg2, _, c2, store2, w2 := newRouter(t) + link(h2, store2, c2, "42", c2.now()) + submit(t, h2, "mesh-delivery", ackAsk(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()) +// An acknowledgement does not end an ask that waits for an approval from where an approval is not proven +// (the review of 2026-10-09): a fresh link's Silence on the held delivery's ask is refused, and the ask stays +// open for Release or Stop once the hour has passed. +func TestAnAcknowledgementDoesNotEndAnAskThatWaitsForAnApproval(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("sync: desktop %+v", dt.sends) + silence := ticketFor(tg.sends[0], "Silence for a week") + if out, err := h.Chosen("telegram", choiceOf(silence, "42", c.now())); err == nil || out != "refused" { + t.Fatalf("a fresh link's acknowledgement ended an ask that offers an approval: %s %v", out, err) } + if len(w.of("mesh-delivery")) != 0 || store.asks["mesh-delivery.d-1"].State != StateOpen { + t.Fatal("the ask ended") + } + // Nor while the controller does not say root is free there. + c.pass(time.Hour) + stand(h, c) + rootFreeOf[h]["anchor"] = "not measured" + again := choiceOf(silence, "42", c.now()) + again.ID = "again" + if out, _ := h.Chosen("telegram", again); out != "refused" || store.asks["mesh-delivery.d-1"].State != StateOpen { + t.Fatalf("an acknowledgement ended the ask while root is not free: %s", out) + } + delete(rootFreeOf[h], "anchor") + release := choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now()) + if out, err := h.Chosen("telegram", release); err != nil || out != "chosen" { + t.Fatalf("the approval after the hour: %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 TestTheDeskAcknowledgesAndNeverApproves(t *testing.T) { + h, _, dt, c, _, w := newRouter(t) + submit(t, h, "mesh-delivery", ackAsk(c, "d-1")) + settle(h, c) + 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) } - 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") + 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(), "cannot answer a question that asks for an approval") { + t.Errorf("the desk approved: %s %v", out, err) + } + // Nor does an acknowledgement forged onto the desk end that ask. + silence = ticketFor(tg2.sends[0], "Silence for a week") + tk = rec.Tickets[silence] + tk.Kind = "desktop" + rec.Tickets[silence] = tk + store2.asks["mesh-delivery.d-1"] = rec + h2.keepAsk("mesh-delivery.d-1", rec, store2.revs["mesh-delivery.d-1"]) + if out, _ := h2.Chosen("desktop", choiceOf(silence, "g14", c2.now())); out != "refused" || + store2.asks["mesh-delivery.d-1"].State != StateOpen { + t.Errorf("a desk click ended an ask that waits for an approval: %s", out) + } +} + +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 := ackAsk(c, "d-"+string(rune('0'+i))) + a.About = "about-" + string(rune('0'+i)) + submit(t, h, "mesh-delivery", a) + } + 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 := ackAsk(c, "d-1") + a.About = "delivery.hq" + submit(t, h, "mesh-delivery", a) + b := ackAsk(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", ackAsk(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 := ackAsk(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") + } +} + +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") + } +} + +// novox/hq ADR 0259 §7 (revised): unlinking is an approve ask put to the account that is linked; its notice +// reaches that account first, by name; nobody else's answer unlinks it. +func TestUnlinkingIsAskedOfTheLinkedAccountAndToldToItFirst(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + got, err := h.Unlink("telegram") + if err != nil || got["asked"] != true { + t.Fatalf("%v %v", got, err) + } + if len(store.ids) != 1 { + t.Fatal("unlinked before the account answered") + } + ask := tg.sends[len(tg.sends)-1] + if ask.Title != "Urgent: Unlink this account?" || tg.to[len(tg.to)-1] != "42" || labelsOf(ask) != "Unlink" { + t.Fatalf("the question: %q to %q offers %q", ask.Title, tg.to[len(tg.to)-1], labelsOf(ask)) + } + if last := dt.sends[len(dt.sends)-1]; !strings.Contains(last.Title, "An unlink of your Telegram account was asked") { + t.Errorf("the desk was not told: %q", last.Title) + } + unlink := ticketFor(ask, "Unlink") + // Another account cannot answer it. + if out, _ := h.Chosen("telegram", choiceOf(unlink, "666", c.now())); out != "refused" || len(store.ids) != 1 { + t.Fatalf("another account unlinked it: %s", out) + } + before := len(tg.sends) + if out, err := h.Chosen("telegram", choiceOf(unlink, "42", c.now())); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + if len(store.ids) != 0 { + t.Fatal("the account's own approval did not unlink it") + } + notice := tg.sends[before] + if notice.Title != "This account is no longer linked as you" || tg.to[before] != "42" { + t.Errorf("the account was not told first, by name: %q to %q", notice.Title, tg.to[before]) + } + if last := dt.sends[len(dt.sends)-1]; !strings.Contains(last.Title, "No Telegram account is linked as you") { + t.Errorf("the desk was not told: %q", last.Title) + } +} + +// A link within a day of an unlink waits for the replaced account's approval. +func TestARelinkWithinADayWaitsForTheReplacedAccount(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + _, _ = h.Unlink("telegram") + _, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now())) + h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}}) + code := codeOf(t, dt.sends[len(dt.sends)-1]) + a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code}) + if !a.Accepted || !strings.Contains(a.Words, "waits for that account's approval") || len(store.ids) != 0 { + t.Fatalf("linked at once within a day of an unlink: %+v %v", a, store.ids) + } + asked := tg.sends[len(tg.sends)-1] + if asked.Title != "Urgent: Allow another account in your place?" || tg.to[len(tg.to)-1] != "42" { + t.Fatalf("the replaced account was not asked: %q to %q", asked.Title, tg.to[len(tg.to)-1]) + } + if out, _ := h.Chosen("telegram", choiceOf(ticketFor(asked, "Allow"), "666", c.now())); out != "refused" { + t.Error("the new account allowed itself") + } + if out, err := h.Chosen("telegram", choiceOf(ticketFor(asked, "Allow"), "42", c.now())); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + if id, ok := store.ids[identityKey("telegram", "666")]; !ok || id.ApproveFrom.Sub(id.Linked) != time.Hour { + t.Errorf("not linked on the replaced account's approval: %+v", store.ids) + } +} + +// Root at the router's machine unlinks, or allows a waiting link, by a file only root makes. It acts from the +// router's minute (Tick), which holds the work: the review of 2026-10-09 found the minute took the work again +// there and hung for ever the first time root used it. It is said on every channel, and the router's own open +// questions end. +func TestBreakGlassIsAFileRootMadeAndTheMinuteGoesOn(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + h.StateDir = t.TempDir() + tick := func() { + t.Helper() + done := make(chan struct{}) + go func() { h.Tick(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("the router's minute hung on a break-glass file") + } + } + // Who owns the file is what the kernel says, faked here both ways: the build seat runs this suite as root, where + // every file the test makes is root's, and a person runs it as themselves (the forge's check of 2026-10-09 failed + // on the first and passed on the second). + was := fileOwner + t.Cleanup(func() { fileOwner = was }) + fileOwner = func(os.FileInfo) uint32 { return 1000 } + if err := os.WriteFile(filepath.Join(h.StateDir, "unlink.telegram"), nil, 0o600); 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) + tick() + if len(store.ids) != 1 || !strings.Contains(dt.sends[len(dt.sends)-1].Title, "was refused") { + t.Fatalf("a file not made by root unlinked: %v", store.ids) + } + // An open question of the router's own: root's act ends it. + if _, err := h.Unlink("telegram"); err != nil { + t.Fatal(err) + } + fileOwner = func(os.FileInfo) uint32 { return 0 } + _ = os.WriteFile(filepath.Join(h.StateDir, "unlink.telegram"), nil, 0o600) + tick() + if len(store.ids) != 0 { + t.Fatal("root's file did not unlink") + } + if _, err := os.Stat(filepath.Join(h.StateDir, "unlink.telegram")); err == nil { + t.Error("the file was left to act again") + } + for _, ch := range []*fakeChannel{tg, dt} { + said := false + for _, m := range ch.sends { + said = said || (m.Title == "Urgent: Root changed who is you" || strings.Contains(m.Title, "Root changed who is you")) && + strings.Contains(m.Body, "was unlinked") && strings.Contains(m.Body, "1 open question") + } + if !said && ch == dt { + t.Errorf("%s was not told what root did: %+v", ch.name, ch.sends[len(ch.sends)-1]) + } + } + for k, rec := range store.asks { + if rec.Asker == SelfAsker && rec.State == StateOpen { + t.Errorf("the router's own question %s is still open after root decided", k) + } + } +} + +// While an agent can become root where the router or the channel runs, nothing proven there approves. +func TestNoApprovalWhileAnAgentCanBecomeRootThere(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + asked := heldAsk(c, "d-1") + submit(t, h, "mesh-delivery", asked) + settle(h, c) + release := ticketFor(tg.sends[0], "Release") + rootFreeOf[h]["anchor"] = "the agent account is not judged" + if out, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err == nil || out != "refused" || + !strings.Contains(err.Error(), "may become root on anchor") { + t.Fatalf("approved while an agent can become root there: %s %v", out, err) + } + if len(w.of("mesh-delivery")) != 0 { + t.Error("a warrant was issued") + } + if got := h.carriers(asks.Approve); len(got) != 0 { + t.Errorf("an approval is still offered on %v", got) + } +} + +// Another asker naming a condition's key does not take that condition's message over. +func TestOnlyTheConditionsOwnerAsksOnItsMessage(t *testing.T) { + h, _, _, c, store, _ := newRouter(t) + k := cond("delivery.hq.held", Warning, "the delivery of hq is held") + k.Raised = c.now() + h.Condition(EventRaised, k) + a := heldAsk(c, "d-1") + a.About = "delivery.hq.held" + submit(t, h, "mesh-delivery", a) + if got := store.asks["mesh-delivery.d-1"].Record; got == "delivery.hq.held" { + t.Error("another asker took the condition's message") + } +} + +// A channel that does not render exactly as the router does carries no answer. +func TestAnAnswerNeedsAChannelThatRendersExactly(t *testing.T) { + h, _, _, c, _, _ := newRouter(t) + h.Bench.SetKinds([]KindInfo{{Kind: "desktop", Module: "desk-channel", Capabilities: []string{"deliver", "choice", "private"}}}) + h.Bench.Stand("desktop", Standing{Ready: true, At: c.now()}) + if got := h.carriers(asks.Acknowledge); len(got) != 0 { + t.Errorf("a channel without exact-render carries %v", got) + } +} + +// At most three links an hour per kind, and the code is shown only where the words stay private. +func TestLinksAreBoundedAndTheCodeShownOnlyInPrivate(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + jo := asks.Sender{Identity: "42", Display: "Jo", Authenticated: true} + for i := 0; i < LinksAnHour; i++ { + h.Link("telegram", asks.LinkAsked{ID: fmt.Sprint("l", i), At: c.now(), Sender: jo}) + } + shown := len(dt.sends) + h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: jo}) + if len(dt.sends) != shown || !strings.Contains(tg.sends[len(tg.sends)-1].Body, "Try again in an hour") { + t.Error("a fourth link this hour showed a code") + } + if !strings.Contains(dt.sends[0].Body, "(number 42)") { + t.Errorf("the notice does not name the account by its number: %q", dt.sends[0].Body) + } + h2, tg2, _, c2, store2, _ := newRouter(t) + h2.Bench.SetKinds([]KindInfo{ + {Kind: "telegram", Module: "telegram", Capabilities: []string{"choice", "verified-sender", "exact-render", "reaches-away"}}, + {Kind: "desktop", Module: "desk-channel", Capabilities: []string{"choice", "exact-render"}}}) + h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: jo}) + if _, pending := store2.links["telegram"]; pending || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "No private place") { + t.Error("a code was shown where the words are not private") + } + _ = store +} + +// A warning the desk could not show goes to the channel that reaches the operator away. +func TestAWarningTheDeskCouldNotShowGoesAway(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + k := cond("module.x.unhealthy", Warning, "x is not healthy") + k.Raised = c.now() + h.Condition(EventRaised, k) + settle(h, c) + if len(dt.sends) != 1 { + t.Fatalf("the desk was sent %d", len(dt.sends)) + } + h.Failed("desktop", asks.Failed{Handle: "g14=1", Why: "no session answers", At: c.now()}) + c.pass(BurstWindow) + h.Flush() + if len(tg.sends) == 0 { + t.Error("a warning the desk could not show did not go to Telegram") + } +} + +// codeOf reads the six digits a link's notice shows. +func codeOf(t *testing.T, m Message) string { + t.Helper() + i := strings.Index(m.Body, ": ") + for i >= 0 { + rest := m.Body[i+2:] + if len(rest) >= 7 && rest[3] == ' ' { + return rest[:3] + rest[4:7] + } + j := strings.Index(rest, ": ") + if j < 0 { + break + } + i += 2 + j + } + t.Fatalf("no code in %q", m.Body) + return "" +} + +// An ask's message goes alone even when it offers one answer: folded into a burst's digest, its one button was +// lost (found by the lab's proof, mesh-lab asks/, with a condition's message in the same burst). +func TestAnAsksMessageWithOneAnswerIsNotFoldedIntoADigest(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + settle(h, c) + // Something was just said on the channel, so what follows waits for the burst. + submit(t, h, "mesh-delivery", heldAsk(c, "d-0")) + settle(h, c) + k := cond("machine.anchor.disk", Urgent, "the disk on anchor is full") + k.Raised = c.now() + h.Condition(EventRaised, k) + before := len(tg.sends) + one := heldAsk(c, "d-1") + one.Options = one.Options[2:] // Silence alone: one answer + submit(t, h, "mesh-delivery", one) + settle(h, c) + var asked *Message + for i := range tg.sends[before:] { + m := tg.sends[before+i] + if ticketFor(m, "Silence for a week") != "" { + asked = &m + } + } + if asked == nil { + t.Fatalf("the ask's one answer was not offered on Telegram: %+v", tg.sends[before:]) + } + if strings.Contains(asked.Body, "disk on anchor") { + t.Errorf("the ask's message was folded with another: %q", asked.Body) } } diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go new file mode 100644 index 00000000..25c51d6a --- /dev/null +++ b/modules/messenger/cmd/messenger/asks.go @@ -0,0 +1,1038 @@ +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"` + // Only is the one account that may answer it, on its kind: an ask of the router's own, put to the account + // an unlink or a relink concerns (novox/hq ADR 0259 §7). + Only *Identity `json:"only,omitempty"` + // Act is what the router does on its own ask's warrant: unlink, or link (selfact.go). + Act *SelfAct `json:"act,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 + GetUnlinked(kind string) (*Unlinked, error) + PutUnlinked(kind string, u Unlinked) error + // GetLinksAsked and PutLinksAsked keep when links were asked, by account and by kind (allowLink). + GetLinksAsked(key string) ([]time.Time, error) + PutLinksAsked(key string, times []time.Time) 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, where the controller says now +// that no agent can become root (rootFree, asked live). +func (h *Holder) carriers(level asks.Level) []string { + if h.Bench == nil { + return nil + } + var out []string + for _, k := range h.Bench.Kinds() { + // Every level needs a choice made in one act, shown exactly as the router rendered it (ADR 0234 §8). + if h.Bench.Ready(k.Kind) != nil || !h.Bench.Promises(k.Kind, "choice") || !h.Bench.Promises(k.Kind, "exact-render") { + 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) && h.rootFree(k, true) == "" { + out = append(out, k.Kind) + } + } + // Destroy needs a code, which no channel carries yet. + } + return out +} + +// RootVerdict is the controller's word on one machine (its verb root-free, novox/hq ADR 0259 §8): free only +// when it measured, recently, that no agent can become root there without a person. +type RootVerdict struct { + Machine string `json:"machine"` + Free bool `json:"free"` + Why string `json:"why"` + Judged string `json:"judged,omitempty"` +} + +// RootFreeVerb is the controller's verb the router asks, as this module, which machines are root-free. +const RootFreeVerb = "seat:" + ControllerSeat + ".root-free" + +// RootFreeWithin is how long the router waits for the controller's word; RootFreeKept how long a word is +// used to render a message's buttons. An answer is always judged on a word asked then. +const ( + RootFreeWithin = 10 * time.Second + RootFreeKept = 30 * time.Second +) + +// askRootFree asks the controller about machines, failing on no answer in time. +func (h *Holder) askRootFree(machines []string) (map[string]RootVerdict, error) { + if h.RootFree != nil { + return h.RootFree(machines) + } + if h.Ask == nil { + return nil, errors.New("this router cannot ask the controller") + } + type answer struct { + raw json.RawMessage + err error + } + done := make(chan answer, 1) + go func() { + raw, err := h.Ask(RootFreeVerb, map[string]any{"machines": machines}) + done <- answer{raw, err} + }() + var a answer + select { + case a = <-done: + case <-time.After(RootFreeWithin): + return nil, fmt.Errorf("the controller did not answer within %s", RootFreeWithin) + } + if a.err != nil { + return nil, a.err + } + var body struct { + Machines []RootVerdict `json:"machines"` + } + if err := json.Unmarshal(a.raw, &body); err != nil { + return nil, fmt.Errorf("the controller's answer could not be read: %v", err) + } + out := map[string]RootVerdict{} + for _, v := range body.Machines { + out[v.Machine] = v + } + return out, nil +} + +// rootFree is why a kind's proof is not believed now, or "" when the controller says, now, that no agent can +// become root without a person on the router's machine and on the kind's (novox/hq ADR 0259 §8, rule 2, as +// the review of 2026-10-09 asked: a positive, recent pass, never the absence of a condition). Anything else +// fails: a router that does not know its machine, a kind whose machine is not said, a controller that does +// not answer, a machine missing from its answer, a word that is not free. cached allows a word up to +// RootFreeKept old, for rendering; an answer is always judged with cached false. +func (h *Holder) rootFree(k KindInfo, cached bool) string { + if h.Node == "" { + return "this router does not know which machine it runs on, so it cannot ask whether an agent can become root there" + } + if k.Node == "" { + return "the mesh has not said which machine " + Display(k.Kind) + " runs on" + } + machines := []string{h.Node} + if k.Node != h.Node { + machines = append(machines, k.Node) + } + now := h.Now() + key := strings.Join(machines, ",") + h.mu.Lock() + kept, ok := h.rootWord[key] + h.mu.Unlock() + var verdicts map[string]RootVerdict + switch { + case cached && ok && now.Sub(kept.at) < RootFreeKept && now.Sub(kept.at) >= 0: + verdicts = kept.verdicts + case cached: + // Read inside the work, which never waits on the controller: no recent word is no pass. warmRootFree + // asks outside it. + return "the controller's word on whether an agent can become root there is not recent" + default: + got, err := h.askRootFree(machines) + if err != nil { + // A word that could not be asked again is not kept from before: no recent word, no pass. + h.mu.Lock() + delete(h.rootWord, key) + h.mu.Unlock() + return "whether an agent can become root where it runs could not be asked: " + err.Error() + } + verdicts = got + h.mu.Lock() + if h.rootWord == nil { + h.rootWord = map[string]rootWord{} + } + h.rootWord[key] = rootWord{at: now, verdicts: got} + h.mu.Unlock() + } + for _, m := range machines { + v, said := verdicts[m] + switch { + case !said: + return "the controller said nothing of whether an agent can become root on " + m + case !v.Free: + why := v.Why + if why == "" { + why = "it is not measured that it cannot" + } + return "a program working for you may become root on " + m + " without asking you (" + why + + "), so an answer proven there proves nothing until that changes" + } + } + return "" +} + +// warmRootFree asks the controller's root-free word for every kind that proves its sender, outside the work, +// so what is rendered and offered inside it reads a recent word. +// force asks even a recent word again: taking an ask, whose refusal its asker keeps until something changes. +func (h *Holder) warmRootFree(force bool) { + if h.Bench == nil { + return + } + now := h.Now() + for _, k := range h.Bench.Kinds() { + if !h.Bench.Promises(k.Kind, "verified-sender") || !h.linked(k.Kind) || h.Node == "" || k.Node == "" { + continue + } + key := h.Node + if k.Node != h.Node { + key += "," + k.Node + } + h.mu.Lock() + kept, ok := h.rootWord[key] + h.mu.Unlock() + if !force && ok && now.Sub(kept.at) >= 0 && now.Sub(kept.at) < RootFreeKept/2 { + continue // recent enough: asked again when half its time has gone + } + _ = h.rootFree(k, false) + } +} + +// rootWordFor asks the controller's root-free word for a kind fresh, before the work is taken, and answers +// what judging an answer of that kind then reads: the word for that very kind's placement, or why there is none. +// A kind that does not prove its sender is asked nothing: its answers never approve. +func (h *Holder) rootWordFor(kind string) func(KindInfo) string { + if h.Bench == nil { + return func(KindInfo) string { return "the router does not know the channels yet" } + } + asked, held := h.Bench.Kind(kind) + if !held || !h.Bench.Promises(kind, "verified-sender") { + return func(KindInfo) string { return Display(kind) + " cannot prove who answered" } + } + why := h.rootFree(asked, false) + return func(info KindInfo) string { + if info.Kind != asked.Kind || info.Node != asked.Node || info.Module != asked.Module { + return "where " + Display(kind) + " runs changed while the answer was judged; answer again" + } + return why + } +} + +// rootWord is the controller's last word on a set of machines, and when it was asked. +type rootWord struct { + at time.Time + verdicts map[string]RootVerdict +} + +// approveHolds is why an answer on a kind could not approve now, or "": the kind verifies its sender, an +// account is linked there, and the controller says no agent can become root where it runs. +func (h *Holder) approveHolds(kind string, cached bool) string { + info, held := h.Bench.Kind(kind) + switch { + case !held: + return "this channel is not one the mesh holds for answers" + case !h.Bench.Promises(kind, "verified-sender"): + return Display(kind) + " cannot prove who answered" + case !h.linked(kind): + return "no " + Display(kind) + " account is linked as you" + } + return h.rootFree(info, cached) +} + +// answerable is whether an option of an ask is answered on a kind, by the ask's highest level: an ask that +// offers an approval is answered only where it could be approved, whichever of its options is chosen, so an +// acknowledgement from where approval is not proven never ends a question that waits for one (the review of +// 2026-10-09). +func answerLevel(a asks.Ask) asks.Level { + if a.Highest().Rank() > asks.Acknowledge.Rank() { + return a.Highest() + } + return asks.Acknowledge +} + +// ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which +// raises the conditions. +const ConditionOwner = "mesh-controller" + +// 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.warmRootFree(true) // fresh, before the work: an ask refused for no channel stays refused with its asker + 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{}} + // Every option on the kinds that carry the ask's highest level: an ask with an approval is answered only + // where it could be approved (answerLevel). + for _, kind := range h.carriers(answerLevel(a)) { + for _, o := range a.Options { + 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] + // Shown on a condition's message only when asked by the condition's owner: another asker naming a + // condition's key does not take its message over. + if a.About != "" && asker == ConditionOwner { + if r := h.open[a.About]; r != nil && r.Cleared.IsZero() && r.Origin == "condition" { + 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") + } + // A new message id each time (the review of 2026-10-09): an id anyone could foresee lets a publisher on + // the same stream take it first, and the bus would drop the warrant as a duplicate. The asker acts on its + // own record of the ask once, however many times it hears a warrant. + err := h.Publish(asks.DecidedSubject(w.Asker), w, "warrant."+ticket()) + 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 rec.Asker == SelfAsker { + // The router's own ask: it does what was decided, and tells nobody else (selfact.go). + h.markTold(key) + h.showClosed(rec) + h.selfAct(rec, *w) + return nil + } + 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 + if answerLevel(rec.Ask) != asks.Acknowledge && rec.Asker != SelfAsker { + // Rendered only while approval holds there now, by the controller's recent word. + if h.approveHolds(kind, true) != "" { + return nil + } + } else if rec.Asker == SelfAsker && answerLevel(rec.Ask) != asks.Acknowledge { + if info, held := h.Bench.Kind(kind); !held || h.rootFree(info, true) != "" { + return nil + } + } + 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, root func(KindInfo) 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") || !h.Bench.Promises(kind, "exact-render") { + 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 rec.Only != nil { + // An ask put to one account is answered by that account alone, linked or not any more. + if rec.Only.Kind != kind || rec.Only.Identity != c.Sender.Identity { + return h.refuseChoice(kind, c, "this question was put to another account") + } + only := *rec.Only + id = &only + } + if id == nil { + return h.refuseStranger(kind, c) + } + person.Verified = "user id verified" + } else { + if rec.Only != nil && rec.Only.Kind != kind { + return h.refuseChoice(kind, c, "this question was put to another channel") + } + person.Verified = "a desk click: whoever was at the operator's session on " + c.Sender.Identity + } + // Judged at the ask's highest level, whichever option was chosen (answerLevel): an acknowledgement on an + // ask that offers an approval needs what the approval needs, so it never ends that ask from where an + // approval is not proven. + level := o.Level + if answerLevel(rec.Ask).Rank() > level.Rank() { + level = answerLevel(rec.Ask) + } + proofs := []string{} + switch level { + case asks.Acknowledge: + case asks.Approve: + if !verifies { + return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it cannot answer a question "+ + "that asks for an approval") + } + 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 answer this question from "+id.ApproveFrom.In(h.zone()).Format("15:04")) + } + if why := root(info); why != "" { + return h.refuseChoice(kind, c, why) + } + if o.Level == asks.Approve { + 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, + // The warrant answers this ask as it was taken and shown, with what each option binds (ADR 0259 §6). + AskDigest: rec.Ask.Digest()} + 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 +} + +// refuseStranger refuses an answer from an account that is not the operator's, and tells the operator, by name +// and number, on the account that is linked: a question's buttons reached somebody else (a forwarded message, +// or a phone in other hands). Nothing is said to the other account. +func (h *Holder) refuseStranger(kind string, c asks.Choice) (string, error) { + why := "this account is not linked as the operator" + 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}) + who := c.Sender.Display + if who == "" { + who = "an account" + } + _ = h.showOn(kind, Message{Title: "Another account tried to answer for you", + Body: fmt.Sprintf("The %s account %s (number %s) chose an answer on a question meant for you. It was refused "+ + "and nothing was done. If you did not pass the question on, somebody else can read your messages.", + Display(kind), who, c.Sender.Identity), Urgent: true}) + return "refused", errorf("%s", why) +} + +// 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) +} + +// showOnHandle is showOn, answering the handle the message is known by. +func (h *Holder) showOnHandle(kind string, m Message) (string, error) { + for _, ch := range h.channels() { + if ch.Name() == kind { + id, err := ch.Send(m) + h.noteChannel(kind, err) + return id, 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, To: m.To, + Secret: m.Secret} + if out.To == "" && h.Bench != nil && h.Bench.To != nil { + out.To = h.Bench.To(kind) + } + for _, a := range m.Actions { + out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token}) + } + err := h.Publish(asks.ChannelSubject(asks.Show, kind), out, out.Handle) + h.noteChannel(kind, err) + return out.Handle, err +} + +// 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) + } + if m.To != "" { + out.To = m.To + } + out.Secret = m.Secret + for _, a := range m.Actions { + out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token}) + } + 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..9351544b 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -89,6 +89,13 @@ 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 + // To is the account it is for, when not the one linked: a notice to an account being unlinked, or an + // ask put to the account a relink replaces. + To string + // Secret: shown once, and kept by no channel (a link's code). + Secret bool } // Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label. @@ -145,7 +152,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,17 +222,30 @@ 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 + 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 + // Node is the machine this router runs on; StateDir its own directory, where root leaves a break-glass + // request (selfact.go). + Node string + StateDir string 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. Ask Asker + // RootFree asks the controller's verb root-free about machines; nil asks it through Ask (askRootFree). + RootFree func(machines []string) (map[string]RootVerdict, error) // Zone is the operator's time zone, which every time in a message is said in; nil is this machine's. Zone func() *time.Location @@ -255,6 +278,13 @@ 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 + // lastUnlink is the last unlink of each kind. + lastUnlink map[string]Unlinked + // rootWord is the controller's last word on whether an agent can become root, by the machines asked. + rootWord map[string]rootWord } func (h *Holder) init() { @@ -308,7 +338,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. @@ -521,6 +554,7 @@ func (h *Holder) cleared(key string, quiet bool) { // Tick does what time asks: reminders, retries, what is held, and forgetting what cleared long // enough ago that a new raise is a new message. func (h *Holder) Tick() { + h.warmRootFree(false) // outside the work: what is offered inside it reads a recent word h.work.Lock() defer h.work.Unlock() h.mu.Lock() @@ -532,6 +566,8 @@ 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) + h.breakGlass() for _, r := range recs { switch { case !r.Cleared.IsZero(): @@ -574,6 +610,7 @@ func (h *Holder) Tick() { // Flush sends what is due on each channel; the runtime calls it every few seconds, so a held // message waits its window and not a minute more. func (h *Holder) Flush() { + h.warmRootFree(false) // outside the work: what is offered inside it reads a recent word h.work.Lock() defer h.work.Unlock() h.mu.Lock() @@ -592,7 +629,73 @@ 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 + } + withheld := map[string]bool{} // the labels of answers whose effect could not be said + if rec := h.openAsk(r); rec != nil { + // Who asks, as the bus proves it (the asker is the subject's, never the body's), what each answer does, + // and what happens if nobody answers (the review of 2026-10-09). + for _, line := range h.askLines(rec) { + if _, ok := Check(line); ok { + m.Body = strings.TrimSpace(m.Body + "\n" + line) + } + } + // **An answer whose effect is not said is not offered** (the confirmation review of 2026-10-09): its line + // refused by the content rule, its button went out all the same, and a tap would authorise what the + // person was not shown. + for _, o := range rec.Ask.Options { + if _, ok := Check(o.Label + ": " + o.Does + "."); !ok || strings.TrimSpace(o.Does) == "" { + withheld[o.Label] = true + } + } + if line := h.elsewhere(rec, kind); line != "" { + if _, ok := Check(line); ok { + m.Body = strings.TrimSpace(m.Body + "\n" + line) + } + } + } + for _, c := range h.choices(r, kind) { + if !withheld[c.Label] { + m.Actions = append(m.Actions, c) + } + } + return m +} + +// askLines are what an ask's message says of the ask itself: who asks, each option's effect, and its expiry. +func (h *Holder) askLines(rec *AskRecord) []string { + asker := rec.Asker + if asker == SelfAsker { + asker = "the router itself" + } + lines := []string{"Asked by " + asker + "."} + for _, o := range rec.Ask.Options { + if o.Does != "" { + lines = append(lines, o.Label+": "+o.Does+".") + } + } + if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" { + lines = append(lines, "If nobody answers by "+rec.Ask.Expires.In(h.zone()).Format("15:04")+": "+rec.Ask.OnExpiry+".") + } + return lines +} + +// 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 +709,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 +743,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..1f11b367 100644 --- a/modules/messenger/cmd/messenger/holder_test.go +++ b/modules/messenger/cmd/messenger/holder_test.go @@ -9,6 +9,7 @@ import ( ) type fakeChannel struct { + to []string // whom each send was for, by account; "" for the linked one name string notReady error fail error @@ -29,6 +30,7 @@ func (f *fakeChannel) Send(m Message) (string, error) { } f.n++ f.sends = append(f.sends, m) + f.to = append(f.to, m.To) if f.name == "desktop" { return fmt.Sprintf("g14=%d", f.n), nil // as the desktop answers: the machine and the notification } @@ -108,7 +110,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 +165,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 +243,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 +252,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 +367,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 +398,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 +418,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 +435,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..00beb17b --- /dev/null +++ b/modules/messenger/cmd/messenger/link.go @@ -0,0 +1,390 @@ +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"` + // Confirmed: the code was right, and the link waits for the account it replaces. + Confirmed bool `json:"confirmed,omitempty"` +} + +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 } + +// LinksAnHour is how many links one account may ask in an hour, and KindLinksAnHour how many all accounts +// together may ask of one kind: kept in the router's state, so a restart forgets neither (the review of +// 2026-10-09). Per account, so a stranger's tries do not stop the operator's. +const ( + LinksAnHour = 3 + KindLinksAnHour = 10 +) + +// allowLink counts a link asked by an account on a kind, and says why not when either bound is reached, or +// the count cannot be kept: an uncounted link is not begun. +func (h *Holder) allowLink(kind, identity string, now time.Time) string { + for _, b := range []struct { + key string + most int + words string + }{ + {"asked." + kind + "." + kvKey(identity), LinksAnHour, fmt.Sprintf("This account asked to be linked %d times this hour. Try again in an hour.", LinksAnHour)}, + {"asked." + kind, KindLinksAnHour, fmt.Sprintf("Links were asked %d times this hour on %s. Try again in an hour.", KindLinksAnHour, Display(kind))}, + } { + times, err := h.Asks.GetLinksAsked(b.key) + if err != nil { + return "How often links were asked cannot be read, so none is begun: " + err.Error() + } + var recent []time.Time + for _, t := range times { + if now.Sub(t) < time.Hour && now.Sub(t) >= 0 { + recent = append(recent, t) + } + } + if len(recent) >= b.most { + return b.words + } + } + for _, key := range []string{"asked." + kind + "." + kvKey(identity), "asked." + kind} { + times, _ := h.Asks.GetLinksAsked(key) + var recent []time.Time + for _, t := range times { + if now.Sub(t) < time.Hour && now.Sub(t) >= 0 { + recent = append(recent, t) + } + } + if err := h.Asks.PutLinksAsked(key, append(recent, now)); err != nil { + return "How often links were asked cannot be kept, so none is begun: " + err.Error() + } + } + return "" +} + +// offerDeskUnlink asks at the desk whether an account still in its first hour is unlinked: it cannot approve +// yet, so whoever is at the desk may undo it (ADR 0259 §7, the review of 2026-10-09). One such question per +// account at a time. Answers whether it was asked. +func (h *Holder) offerDeskUnlink(id Identity) bool { + desk := "" + for _, k := range h.Bench.Kinds() { + if k.Kind != id.Kind && h.Bench.Promises(k.Kind, "private") && !h.Bench.Promises(k.Kind, "verified-sender") && + h.Bench.Ready(k.Kind) == nil { + desk = k.Kind + break + } + } + if desk == "" { + return false + } + h.mu.Lock() + for _, e := range h.asks { + if e.rec.Asker == SelfAsker && e.rec.State == StateOpen && e.rec.Act != nil && e.rec.Act.Do == "unlink" && + e.rec.Act.Identity == id.Identity && e.rec.Only != nil && e.rec.Only.Kind == desk { + h.mu.Unlock() + return true + } + } + h.mu.Unlock() + now := h.Now() + until := id.ApproveFrom + a := asks.Ask{ID: "desk-unlink-" + id.Kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink the account linked just now?", + Explanation: fmt.Sprintf("The %s account %s (number %s) was linked as you at %s. Another account just asked "+ + "to be linked. If the linked one is not yours, unlink it here before %s; after that only that account, "+ + "or root at the router's machine, can.", Display(id.Kind), id.Display, id.Identity, + id.Linked.In(h.zone()).Format("15:04"), until.In(h.zone()).Format("15:04")), + Who: asks.Operator, Expires: until, OnExpiry: "the account stays linked", + Options: []asks.Option{{ID: "unlink", Label: "Unlink it", Does: "that account stops answering as you", Level: asks.Acknowledge}}} + if err := h.askSelf(a, Identity{Kind: desk}, SelfAct{Do: "unlink", Kind: id.Kind, Identity: id.Identity}); err != nil { + h.Logf("[messenger] the desk could not be asked to unlink %s: %v", id.Display, err) + return false + } + return true +} + +// Link takes a link asked for on a kind: a code shown where only the operator sees it, or a refusal said on +// the kind. +func (h *Holder) Link(kind string, l asks.LinkAsked) string { + h.warmRootFree(false) // outside the work: a question it asks reads a recent word + 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.") + } + now := h.Now() + if 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.") + } + if now.Before(id.ApproveFrom) && h.offerDeskUnlink(id) { + return reply("Another account is linked", "Another account was linked here less than an hour ago. If "+ + "that was not you, unlink it at your desk, where you were just asked, or as root at the router's "+ + "machine; then send /start again.") + } + return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked "+ + "with that account's own approval, or as root at the router's machine, and then this one can be linked.") + } + if u := h.recentUnlink(kind); u != nil && u.Identity.Identity == l.Sender.Identity { + // An account unlinked less than a day ago does not come back by a code at once (the review of + // 2026-10-09): whoever unlinked it meant it, and an account taken with a phone must not walk back in. + return reply("Not linked", "This account was unlinked less than a day ago. It can be linked again from "+ + u.At.Add(RelinkWaits).In(h.zone()).Format("2006-01-02 15:04")+", or allowed sooner as root at the router's machine.") + } + if why := h.allowLink(kind, l.Sender.Identity, now); why != "" { + return reply("Not linked", why) + } + // A code is shown only where the words stay on the operator's machines: a kind declaring `private`. + // It is shown now or never: a desk that cannot show it now (its notifications paused, nobody there) begins + // no link, so no code waits in a queue to be shown to whoever comes later (the review of 2026-10-09). + shows := "" + var whyNot []string + for _, k := range h.Bench.Kinds() { + if k.Kind == kind || !h.Bench.Promises(k.Kind, "private") { + continue + } + if err := h.Bench.Ready(k.Kind); err != nil { + whyNot = append(whyNot, err.Error()) + continue + } + shows = k.Kind + break + } + if shows == "" { + why := "No private place can show the code now." + if len(whyNot) > 0 { + why = "Your desk cannot show the code now: " + strings.Join(whyNot, "; ") + "." + } + return reply("Not linked", why+" Nothing was begun. 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: 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" + } + notice := Message{Title: "Link a " + Display(kind) + " account as you", + Body: fmt.Sprintf("The %s account %q (number %s) 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, + l.Sender.Identity, p.Expires.In(h.zone()).Format("15:04"), code[:3], code[3:]), + Urgent: true, Secret: true} + if err := h.showOn(shows, notice); err != nil { + _ = h.Asks.DeleteLink(kind) + return reply("Not linked", "The code could not be shown: "+err.Error()) + } + h.Logf("[messenger] a link was asked on %s by %q (%s); its code is shown on %s", kind, who, l.Sender.Identity, shows) + 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.warmRootFree(false) // outside the work: a question it asks reads a recent word + 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 || p.Confirmed { + return no("No link waits for a code. 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} + u := h.recentUnlink(kind) + if u != nil && u.Identity.Identity == p.Identity { + _ = h.Asks.DeleteLink(kind) + return no("This account was unlinked less than a day ago, and is not linked again by a code before a day has passed.") + } + if others := h.identitiesOf(kind); len(others) > 0 { + _ = h.Asks.DeleteLink(kind) + return no("Another account was linked here meanwhile. Nothing was linked.") + } + if u != nil { + // Within a day of an unlink: the account it replaces decides (novox/hq ADR 0259 §7). + p.Confirmed = true + p.Expires = now.Add(RelinkWaits) + _ = h.Asks.PutLink(kind, *p) + a := asks.Ask{ID: "relink-" + kind + "-" + fmt.Sprint(now.Unix()), + Headline: "Allow another account in your place?", + Explanation: fmt.Sprintf("This account was unlinked from being you less than a day ago. The %s account %s "+ + "(number %s) now asks to be linked as you. If you choose Allow, it is.", Display(kind), p.Display, p.Identity), + Who: asks.Operator, Expires: now.Add(RelinkWaits - time.Minute), OnExpiry: "nothing is linked", + Options: []asks.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: asks.Approve}}} + prior := u.Identity + prior.ApproveFrom = prior.Linked // an account linked before: its hour passed while it was linked + if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Identity: id.Identity, Link: &id}); err != nil { + return no("The link could not be asked of the account it replaces: " + err.Error()) + } + h.announce(Message{Title: "A new " + Display(kind) + " account asks to be linked as you", + Body: fmt.Sprintf("%s (number %s), less than a day after an unlink. It waits for the account it replaces, "+ + "or for root at the router's machine.", p.Display, p.Identity), Urgent: true}) + return asks.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " + + "than a day ago, the link waits for that account's approval, or for you at the router's machine."} + } + h.linkNow(id, "with the code shown on the desk") + from := now.Add(LinkedApproveAfter).In(h.zone()).Format("15:04") + return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."} +} + +// 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..e4d474bd 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,102 @@ 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, told by its code, as ErrChanged. +func changed(err error) error { + if errors.Is(err, stdio.ErrChanged) { + 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.") && !strings.HasPrefix(k, "unlinked.") && !strings.HasPrefix(k, "asked.") { + 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) +} +func (askStore) GetUnlinked(kind string) (*Unlinked, error) { + e, err := stdio.State("identities").Get("unlinked." + kind) + if err != nil || e == nil { + return nil, err + } + var u Unlinked + return &u, json.Unmarshal(e.Value, &u) +} +func (askStore) PutUnlinked(kind string, u Unlinked) error { + _, err := stdio.State("identities").Put("unlinked."+kind, u) + return err +} + +func (askStore) GetLinksAsked(key string) ([]time.Time, error) { + e, err := stdio.State("identities").Get(key) + if err != nil || e == nil { + return nil, err + } + var times []time.Time + return times, json.Unmarshal(e.Value, ×) +} +func (askStore) PutLinksAsked(key string, times []time.Time) error { + _, err := stdio.State("identities").Put(key, times) + return err +} + // listening is whether condition events reach this holder, in words. type listening struct { mu sync.Mutex @@ -163,18 +244,19 @@ 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{}, + Node: os.Getenv("MESH_NODE"), + StateDir: os.Getenv("MESH_MESSENGER_STATE"), + Bench: bench, + Publish: publish, + Ask: stdio.Ask, + Logf: logf, Zone: func() *time.Location { loc, err := zoneOf(settings().TimeZone) said.Lock() @@ -190,6 +272,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 +338,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 +355,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 +565,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..6484fc2a 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -8,31 +8,43 @@ 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"` - Consumes []string - Emits []string - Invokes []string - State []string - Own map[string]string `json:"own-secrets"` - Tools []string - Build struct { + Module string `json:"module"` + Seats []seat `json:"seats"` + Claims []seat `json:"claims"` + Uses []string + Consumes []string + Emits []string + Invokes []string + State []json.RawMessage + Own map[string]string `json:"own-secrets"` + RunsAs string `json:"runs-as"` + SecretsOwner string `json:"secrets-owner"` + Tools []string + Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } 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 +60,51 @@ 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 verbs it calls read the open conditions and whether root is free. + if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions", + "seat:" + ControllerSeat + ".root-free"}) { 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"]) + // It holds no channel's secret: only its own bus account, read by its own account alone (ADR 0259 §8). + if len(m.Own) != 1 || m.Own["broker"] == "" || m.RunsAs != "messenger" || m.SecretsOwner != "messenger" { + t.Fatalf("own secrets %v, runs as %q, secrets owned by %q", m.Own, m.RunsAs, m.SecretsOwner) } } @@ -99,7 +133,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..d3c473a3 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 } @@ -280,10 +287,12 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) { return } if len(entries) > 1 { - // A message that asks the operator something goes alone, so its answers come with it. + // A message that asks the operator something goes alone, so its answers come with it: one with several + // answers, and one carrying an ask's answer even when only one can be given here (the lab's proof found + // an ask whose approval was not offered folded into a digest, and its one answer lost with it). var alone, rest []entry for _, e := range entries { - if len(e.msg.Actions) > 1 { + if len(e.msg.Actions) > 1 || (len(e.msg.Actions) > 0 && e.r != nil && e.r.Ask != "") { alone = append(alone, e) } else { rest = append(rest, e) @@ -341,7 +350,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 +413,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/review_test.go b/modules/messenger/cmd/messenger/review_test.go new file mode 100644 index 00000000..a5357129 --- /dev/null +++ b/modules/messenger/cmd/messenger/review_test.go @@ -0,0 +1,535 @@ +package main + +// The findings of the review of 2026-10-09, each held here by a test that failed before its fix. + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// H3: approval rests on the controller's positive, recent word that no agent can become root where the router +// and the channel run (its verb root-free), asked live. Every other answer fails closed. +func TestApprovalNeedsTheControllersWordThatRootIsFree(t *testing.T) { + for name, tc := range map[string]struct { + setup func(h *Holder) + want string + }{ + "a router that does not know its machine": {func(h *Holder) { h.Node = "" }, "does not know which machine"}, + "a channel whose machine is not said": {func(h *Holder) { + k, _ := h.Bench.Kind("telegram") + k.Node = "" + h.Bench.SetKinds([]KindInfo{k}) + }, "has not said which machine"}, + "a controller that does not answer": {func(h *Holder) { + h.RootFree = func([]string) (map[string]RootVerdict, error) { return nil, errors.New("no responders") } + }, "could not be asked: no responders"}, + "a machine the answer leaves out": {func(h *Holder) { + h.RootFree = func([]string) (map[string]RootVerdict, error) { return map[string]RootVerdict{}, nil } + }, "said nothing of whether an agent can become root on anchor"}, + "a machine not free": {func(h *Holder) { rootFreeOf[h]["anchor"] = "the agent account is not judged" }, + "may become root on anchor"}, + "the router's own machine not free": {func(h *Holder) { + h.Node = "router-machine" + rootFreeOf[h]["router-machine"] = "execute is served there" + }, "may become root on router-machine"}, + } { + t.Run(name, func(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) + release := ticketFor(tg.sends[0], "Release") + if release == "" { + t.Fatal("not offered while root was free") + } + tc.setup(h) + out, err := h.Chosen("telegram", choiceOf(release, "42", c.now())) + if out != "refused" || err == nil || !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 got := h.carriers(asks.Approve); len(got) != 0 { + t.Errorf("approval still carried on %v", got) + } + }) + } +} + +// H3: the word is asked of the controller's verb, as this module, and read from its answer. +func TestTheRootWordIsAskedOfTheControllersVerb(t *testing.T) { + h, _, _, _, _, _ := newRouter(t) + h.RootFree = nil + var asked []string + h.Ask = func(key string, body any) (json.RawMessage, error) { + asked = append(asked, key) + raw, _ := json.Marshal(body) + if !strings.Contains(string(raw), `"machines":["anchor"]`) { + t.Errorf("asked about %s", raw) + } + return json.RawMessage(`{"machines":[{"machine":"anchor","free":true,"why":"judged","judged":"2026-10-09T10:00:00Z"}]}`), nil + } + info, _ := h.Bench.Kind("telegram") + if why := h.rootFree(info, false); why != "" { + t.Fatalf("free not read: %s", why) + } + if len(asked) != 1 || asked[0] != "seat:mesh-controller.root-free" { + t.Errorf("asked %v", asked) + } + h.Ask = func(string, any) (json.RawMessage, error) { return json.RawMessage(`not json`), nil } + if why := h.rootFree(info, false); !strings.Contains(why, "could not be read") { + t.Errorf("an unreadable answer was a pass: %q", why) + } + h.Ask = nil + if why := h.rootFree(info, false); why == "" { + t.Error("no way to ask was a pass") + } +} + +// H3: a message rendered while root is not free offers no approval; a word kept for rendering is never the +// word an answer is judged on. +func TestAMessageRenderedWhileRootIsNotFreeOffersNoApproval(t *testing.T) { + h, tg, _, c, store, _ := 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) + rec := h.openAsk(h.open["ask.mesh-delivery.d-1"]) + if got := h.offers(rec, "telegram"); len(got) != 3 { + t.Fatalf("offers %v", got) + } + rootFreeOf[h]["anchor"] = "not judged" + c.pass(RootFreeKept) + if got := h.offers(rec, "telegram"); len(got) != 0 { + t.Errorf("rendered with an approval while root is not free: %v", got) + } + // Free again, but the kept word is not: the answer asks anew and is taken. + delete(rootFreeOf[h], "anchor") + out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now())) + if err != nil || out != "chosen" { + t.Errorf("judged on a kept word: %s %v", out, err) + } +} + +// H3: a capability the controller's word no longer says stays no longer than the word is fresh: a router that +// cannot read the kinds again promises no verified sender. +func TestAStaleWordOnTheKindsPromisesNoVerifiedSender(t *testing.T) { + h, _, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + if !h.Bench.Promises("telegram", "verified-sender") { + t.Fatal("not promised while fresh") + } + c.pass(KindsFresh + time.Second) + h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()}) + if h.Bench.Promises("telegram", "verified-sender") || len(h.carriers(asks.Approve)) != 0 { + t.Error("a stale word still promises a verified sender") + } + if !h.Bench.Promises("telegram", "choice") { + t.Error("a stale word stopped the channel sending") + } +} + +// C6: a link's code is marked secret on every path to a channel, the desk's own role included, so the channel +// keeps no copy (the review found it dropped where the desk's role sends). +func TestALinksCodeIsSecretOnEveryPath(t *testing.T) { + c := start() + bench := NewBench(c.now) + bench.SetKinds([]KindInfo{ + {Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"reaches-away", "choice", "verified-sender", "exact-render"}}, + {Kind: "desktop", Module: "desk-channel", Node: "anchor", Capabilities: []string{"choice", "exact-render", "private"}}, + }) + bench.Stand("telegram", Standing{Ready: true, At: c.now()}) + bench.Stand("desktop", Standing{Ready: true, At: c.now()}) + var mu sync.Mutex + put := map[string][]asks.Message{} + publish := func(subject string, body any, id string) error { + mu.Lock() + defer mu.Unlock() + if m, ok := body.(asks.Message); ok { + put[subject] = append(put[subject], m) + } + return nil + } + h := &Holder{Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish}, + Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, Bench: bench, Publish: publish, + Asks: newMemAsks(), Node: "anchor", Now: c.now, Logf: t.Logf, Zone: func() *time.Location { return time.UTC }} + h.init() + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + shown := put[asks.ChannelSubject(asks.Send, "desktop")] + if len(shown) != 1 || !strings.Contains(shown[0].Body, "send this code") { + t.Fatalf("the code was not shown on the desk: %+v", put) + } + if !shown[0].Secret { + t.Error("the code went to the desk not marked secret: the channel would keep a copy") + } +} + +// L1: a warrant's message id is new each time: one anyone could foresee lets another publisher on the stream +// take it first, and the bus would drop the warrant as a duplicate. +func TestAWarrantsMessageIdCannotBeForeseen(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + var ids []string + h.Publish = func(subject string, body any, id string) error { + if _, ok := body.(asks.Warrant); ok { + ids = append(ids, id) + } + return nil + } + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + if out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now())); out != "chosen" { + t.Fatalf("%s %v", out, err) + } + _ = h.tell(*store.asks["mesh-delivery.d-1"].Warrant) + if len(ids) != 2 || ids[0] == ids[1] { + t.Fatalf("ids %v", ids) + } + for _, id := range ids { + if strings.Contains(id, "d-1") || strings.Contains(id, "mesh-delivery") { + t.Errorf("the id %q is made of what anyone knows of the ask", id) + } + } +} + +// M2: an ask's message says who asks, as the bus proves it, what each answer does, and what happens if nobody +// answers. +func TestAnAsksMessageSaysWhoAsksWhatEachAnswerDoesAndWhatExpiryDoes(t *testing.T) { + h, tg, _, c, store, _ := 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) + body := tg.sends[0].Body + for _, want := range []string{"Asked by mesh-delivery.", "Release: the delivery goes on.", "Stop: the delivery ends.", + "Silence for a week: nothing more is said of it for a week.", + "If nobody answers by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} { + if !strings.Contains(body, want) { + t.Errorf("the message does not say %q:\n%s", want, body) + } + } +} + +// M3: the router's own unlink binds the account it asks about: an approval from that account, after another was +// linked in its place, unlinks nothing. +func TestAnUnlinkUnlinksOnlyTheAccountItNamed(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + if _, err := h.Unlink("telegram"); err != nil { + t.Fatal(err) + } + unlink := ticketFor(tg.sends[len(tg.sends)-1], "Unlink") + delete(store.ids, identityKey("telegram", "42")) + store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Display: "Al", + Linked: c.now().Add(-2 * time.Hour), ApproveFrom: c.now().Add(-time.Hour)} + _, _ = h.Chosen("telegram", choiceOf(unlink, "42", c.now())) + if _, still := store.ids[identityKey("telegram", "77")]; !still { + t.Error("an approval to unlink 42 unlinked 77") + } + if got := store.asks; len(got) == 0 { + t.Fatal("no ask") + } + for _, rec := range store.asks { + if rec.Act != nil && rec.Act.Identity != "42" { + t.Errorf("the ask binds %q", rec.Act.Identity) + } + } +} + +// M3: a link is checked again as it is made: an approved relink does not link a second account of a kind. +func TestALinkIsNotMadeWhileAnotherAccountIsLinked(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + _, _ = h.Unlink("telegram") + _, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now())) + h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}}) + code := codeOf(t, dt.sends[len(dt.sends)-1]) + _ = h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code}) + asked := tg.sends[len(tg.sends)-1] + // Meanwhile root linked another account (allowed at the machine). + store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Linked: c.now(), ApproveFrom: c.now().Add(time.Hour)} + _, _ = h.Chosen("telegram", choiceOf(ticketFor(asked, "Allow"), "42", c.now())) + if _, linked := store.ids[identityKey("telegram", "666")]; linked { + t.Errorf("a second account of a kind was linked: %v", store.ids) + } +} + +// M3: an account unlinked less than a day ago is not linked again by a code: no code is shown for it. +func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + _, _ = h.Unlink("telegram") + _, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now())) + shown := len(dt.sends) + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + if _, pending := store.links["telegram"]; pending { + t.Error("a link was begun for the account unlinked an hour ago") + } + for _, m := range dt.sends[shown:] { + if strings.Contains(m.Body, "send this code") { + t.Error("a code was shown for it") + } + } + if len(store.ids) != 0 { + t.Error("linked again") + } +} + +// H4: an account still in its first hour cannot approve yet, so whoever is at the desk may unlink it — asked +// there when another account asks to be linked — and past its hour the desk no longer can. +func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) { + h, tg, dt, c, store, w := newRouter(t) + link(h, store, c, "666", c.now().Add(-10*time.Minute)) // an account the operator did not link + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + if reply := tg.sends[len(tg.sends)-1]; !strings.Contains(reply.Body, "unlink it at your desk") { + t.Fatalf("the sender was not told how: %q", reply.Body) + } + asked := dt.sends[len(dt.sends)-1] + if labelsOf(asked) != "Unlink it" || !strings.Contains(asked.Body, "number 666") { + t.Fatalf("the desk was not asked: %+v", asked) + } + ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now()) + ch.Sender.Authenticated = false + if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + if len(store.ids) != 0 { + t.Errorf("not unlinked: %v", store.ids) + } + if len(w.of(SelfAsker)) != 0 { + t.Error("the router's own act was told to an asker") + } + // Past its hour, no question goes to the desk. + h2, tg2, dt2, c2, store2, _ := newRouter(t) + link(h2, store2, c2, "666", c2.now().Add(-2*time.Hour)) + h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: asks.Sender{Identity: "42", Authenticated: true}}) + for _, m := range dt2.sends { + if labelsOf(m) == "Unlink it" { + t.Error("the desk was asked to unlink an account past its first hour") + } + } + if !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "own approval, or as root") { + t.Errorf("%q", tg2.sends[len(tg2.sends)-1].Body) + } +} + +// H4 (C4): while the desk cannot show a code now — its notifications paused — no link is begun and no code is +// made or queued; the sender is told why. +func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + h.Bench.Stand("desktop", Standing{Ready: false, Why: "notifications are paused on the desk", At: c.now()}) + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + if _, pending := store.links["telegram"]; pending { + t.Error("a link was begun") + } + if len(dt.sends) != 0 { + t.Errorf("something was put to the desk: %+v", dt.sends) + } + if reply := tg.sends[len(tg.sends)-1].Body; !strings.Contains(reply, "notifications are paused on the desk") { + t.Errorf("the sender was not told why: %q", reply) + } +} + +// L5: links are bounded per account, and kept in the router's state: a stranger's tries do not stop the +// operator's link, and a restart forgets no count. +func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + eve := asks.Sender{Identity: "666", Display: "Eve", Authenticated: true} + for i := 0; i < LinksAnHour; i++ { + h.Link("telegram", asks.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve}) + } + shown := len(dt.sends) + h.Link("telegram", asks.LinkAsked{ID: "jo", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + if len(dt.sends) != shown+1 || !strings.Contains(dt.sends[len(dt.sends)-1].Body, "(number 42)") { + t.Fatalf("a stranger's tries stopped the operator's link: %q", tg.sends[len(tg.sends)-1].Body) + } + // A router started again on the same state still counts the stranger's tries. + h2, tg2, dt2, c2, _, _ := newRouter(t) + h2.Asks = store + c2.pass(c.now().Sub(c2.now())) + stand(h2, c2) + before := len(dt2.sends) + h2.Link("telegram", asks.LinkAsked{ID: "e9", At: c2.now(), Sender: eve}) + if len(dt2.sends) != before || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "Try again in an hour") { + t.Error("a restart forgot the count") + } +} + +// L7: two answers to one ask arriving at once — on two routers sharing the state — make exactly one warrant. +func TestTwoAnswersAtOnceMakeOneWarrant(t *testing.T) { + h1, tg, _, c, store, w1 := newRouter(t) + link(h1, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h1, "mesh-delivery", heldAsk(c, "d-1")) + settle(h1, c) + release, stop := ticketFor(tg.sends[0], "Release"), ticketFor(tg.sends[0], "Stop") + h2, _, _, _, _, w2 := newRouter(t) + h2.Asks = store + h2.Now = h1.Now + if err := h2.loadAsks(); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + outs := make([]string, 2) + for i, pick := range []struct { + h *Holder + ticket string + }{{h1, release}, {h2, stop}} { + wg.Add(1) + go func(i int, h *Holder, ticket string) { + defer wg.Done() + outs[i], _ = h.Chosen("telegram", choiceOf(ticket, "42", c.now())) + }(i, pick.h, pick.ticket) + } + wg.Wait() + n := len(w1.of("mesh-delivery")) + len(w2.of("mesh-delivery")) + if n != 1 { + t.Fatalf("%d warrants for one ask (%v)", n, outs) + } + if (outs[0] == "chosen") == (outs[1] == "chosen") { + t.Errorf("not exactly one chosen: %v", outs) + } +} + +// The confirmation review of 2026-10-09, N1-router: another account's /start offers the desk an unlink of the +// operator's account while it is in its first hour; a desk click — which a program on the desk machine can forge +// — unlinked it and barred it for a day, again and again, so the operator never approved anything. A desk +// unlink in the first hour bars nothing: the operator links again at once with a code from the desk, and the +// next link waits for nobody. The bar is for an account removed deliberately later. +func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-10*time.Minute)) // the operator's own, linked just now + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "X", Authenticated: true}}) + asked := dt.sends[len(dt.sends)-1] + ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now()) // a click nobody can prove was the operator's + ch.Sender.Authenticated = false + if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + if len(store.ids) != 0 { + t.Fatalf("not unlinked: %v", store.ids) + } + if u := h.recentUnlink("telegram"); u != nil { + t.Errorf("a desk unlink in the first hour bars relinking: %+v", u) + } + // The operator sends /start again: a code is shown at once, and the code links without waiting for anybody. + c.pass(time.Minute) + stand(h, c) + h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + code := "" + for _, m := range dt.sends { + if strings.Contains(m.Body, "number 42") && strings.Contains(m.Body, "send this code") { + code = codeIn(t, m.Body) + } + } + if code == "" { + t.Fatalf("no code was shown to relink the operator's account; said %q", tg.sends[len(tg.sends)-1].Body) + } + if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}); !a.Accepted || + !strings.Contains(a.Words, "Linked") { + t.Fatalf("the operator's code was not taken at once: %+v", a) + } +} + +// And break-glass lifts any bar: root's allow-link at the router's machine, with no link waiting, ends a day's bar. +func TestBreakGlassLiftsABarOnRelinking(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + _, _ = h.Unlink("telegram") + _, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now())) + if h.recentUnlink("telegram") == nil { + t.Fatal("the control: a deliberate unlink bars relinking for a day") + } + h.StateDir = t.TempDir() + was := fileOwner + t.Cleanup(func() { fileOwner = was }) + fileOwner = func(os.FileInfo) uint32 { return 0 } + _ = os.WriteFile(filepath.Join(h.StateDir, "allow-link.telegram"), nil, 0o600) + h.Tick() + if u := h.recentUnlink("telegram"); u != nil { + t.Errorf("root's allow-link left the bar: %+v", u) + } +} + +// codeIn is the six digits a link's notice shows, "123 456". +func codeIn(t *testing.T, body string) string { + t.Helper() + m := regexp.MustCompile(`(\d{3}) (\d{3})\.`).FindStringSubmatch(body) + if m == nil { + t.Fatalf("no code in %q", body) + } + return m[1] + m[2] +} + +// The confirmation review of 2026-10-09 (low): the router asked the controller's root-free word — up to ten +// seconds — while holding its work, so every ask, answer and link waited behind one slow controller. It now +// asks before it takes the work, and judges with the word it was given then. +func TestTheRootFreeWordIsNotAskedWhileTheWorkIsHeld(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) + release := ticketFor(tg.sends[0], "Release") + asking, slow := make(chan struct{}), make(chan struct{}) + var once sync.Once + free := h.RootFree + h.RootFree = func(machines []string) (map[string]RootVerdict, error) { + once.Do(func() { close(asking); <-slow }) + return free(machines) + } + done := make(chan string, 1) + go func() { + out, _ := h.Chosen("telegram", choiceOf(release, "42", c.now())) + done <- out + }() + <-asking + linked := make(chan struct{}) + go func() { + h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "77", Authenticated: true}}) + close(linked) + }() + select { + case <-linked: + case <-time.After(2 * time.Second): + close(slow) + t.Fatal("a link waited for the controller's root-free word: the work was held while it was asked") + } + close(slow) + if out := <-done; out != "chosen" || len(w.of("mesh-delivery")) != 1 { + t.Fatalf("the answer after the word: %s, %v", out, w.of("mesh-delivery")) + } +} + +// The confirmation review of 2026-10-09 (low, on M2): an answer whose line — what it does — the content rule +// withholds is not offered either; its button went out alone, and a tap would choose what was not shown. +func TestAnAnswerWhoseEffectIsWithheldIsNotOffered(t *testing.T) { + h, _, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + key := askKey("mesh-delivery", "d-1") + h.mu.Lock() + e := h.asks[key] + e.rec.Ask.Options[0].Does = "the delivery reads /etc/shadow" + h.asks[key] = e + r := h.open["ask."+key] + h.mu.Unlock() + m := h.sayOn(r, "asked", "telegram") + for _, a := range m.Actions { + if a.Label == "Release" { + t.Errorf("Release was offered with its effect withheld: %+v", m.Actions) + } + } + if strings.Contains(m.Body, "/etc/shadow") { + t.Errorf("the withheld words were said: %q", m.Body) + } + if len(m.Actions) == 0 { + t.Error("the answers whose effect was said were not offered either") + } +} diff --git a/modules/messenger/cmd/messenger/seatchannel.go b/modules/messenger/cmd/messenger/seatchannel.go new file mode 100644 index 00000000..36446ec9 --- /dev/null +++ b/modules/messenger/cmd/messenger/seatchannel.go @@ -0,0 +1,329 @@ +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 + knownAt time.Time +} + +// KindsFresh is how long the controller's word on the kinds is believed for a proof: the router reads it every +// half minute, so an older word is one it could not read again. A stale word keeps the kinds for sending, and +// promises no verified sender (the review of 2026-10-09: a capability the controller withdrew must not stay). +const KindsFresh = 2 * time.Minute + +// 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 + b.knownAt = b.Now() +} + +// 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 + } + if capability == "verified-sender" { + b.mu.Lock() + at := b.knownAt + b.mu.Unlock() + if age := b.Now().Sub(at); age > KindsFresh || age < 0 { + 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) + case b.Promises(kind, "verified-sender") && b.To != nil && b.To(kind) == "": + // A channel proving its sender says only to the account linked as the operator: none linked, it has + // nobody to say anything to (the correctness review of 2026-10-08). + return fmt.Errorf("no %s account is linked as the operator: send /start to the bot", Display(kind)) + } + 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 + } + // Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it + // dropped here, where the desk's own role sends). + out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, + Secret: m.Secret} + if c.Bench.To != nil { + out.To = c.Bench.To(kind) + } + if m.To != "" { + out.To = m.To + } + 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/selfact.go b/modules/messenger/cmd/messenger/selfact.go new file mode 100644 index 00000000..bba67109 --- /dev/null +++ b/modules/messenger/cmd/messenger/selfact.go @@ -0,0 +1,370 @@ +package main + +// The router's own asks (novox/hq ADR 0259 §7, as revised after the security review of 2026-10-08): who the +// operator is on a channel changes only with the word of the account it is now. +// +// - **Unlinking is an approve ask, put to the account that is linked.** Whoever could unlink the +// operator's account without it could link their own in its place. The notice of the unlink reaches that +// account first, by name, and then every channel. +// - **A link within a day of an unlink waits for the replaced account's approval**, asked of it by name. +// - **When that account is lost with its phone**, the operator, as root at the router's machine, leaves a +// file in the router's directory — `unlink.` or `allow-link.`, owned by root — and the router +// does it, announced as done at the machine. A file of any other owner is refused and said: an agent +// without root cannot make one. + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// SelfAsker is the name the router asks its own questions under. +const SelfAsker = "messenger" + +// RelinkWaits is how long after an unlink a new link waits for the replaced account's approval. +const RelinkWaits = 24 * time.Hour + +// SelfAct is what the router does on its own ask's warrant. It names the account it concerns: an option +// binds that account, so a warrant for unlinking one account never unlinks another linked in its place +// (the review of 2026-10-09). +type SelfAct struct { + Do string `json:"do"` // "unlink" or "link" + Kind string `json:"kind"` + Identity string `json:"identity"` // the account unlinked, or the account a link makes the operator's + Link *Identity `json:"link,omitempty"` // the account a link makes the operator's +} + +// act is what an option of the router's own ask binds: its named fields (asks.ActDigest). +func (a SelfAct) act() asks.Act { + return asks.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity} +} + +// Unlinked is the last account unlinked on a kind, kept a day so a relink is asked of it. +type Unlinked struct { + Identity Identity `json:"identity"` + At time.Time `json:"at"` + // Early is an unlink at the desk in the account's first hour: it bars nothing (the confirmation review of + // 2026-10-09, N1-router). A desk click is anyone's at the desk machine, and a bar it set — the account not + // linked again for a day, the next link waiting for it — let a forged click lock the operator out, again + // and again. The bar is for an account removed deliberately: by its own approval, or by root. + Early bool `json:"early,omitempty"` +} + +// askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask. +func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error { + // Its options bind the act, as any asker's do (ADR 0259 §6): the warrant then performs this act alone. + binds, err := asks.ActDigest(act.act()) + if err != nil { + return err + } + for i := range a.Options { + a.Options[i].Binds = binds + } + key := askKey(SelfAsker, a.ID) + now := h.Now() + rec := AskRecord{Asker: SelfAsker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{}, + Only: &only, Act: &act, Record: "ask." + key} + for _, o := range a.Options { + rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: only.Kind} + } + if h.Asks != nil { + rev, err := h.Asks.CreateAsk(key, rec) + if err != nil { + return err + } + h.keepAsk(key, rec, rev) + } else { + h.keepAsk(key, rec, 0) + } + r := Record{Key: rec.Record, Kind: "ask", Subject: SelfAsker, Severity: Urgent, 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() + m := h.words(&r, "raised") + m.Actions = h.offers(&rec, only.Kind) + m.To = only.Identity + handle, err := h.showOnHandle(only.Kind, m) + if err == nil { + h.mu.Lock() + r.Sent[only.Kind] = handle + r.FirstSent = now + h.mu.Unlock() + } + h.persist(&r) + return err +} + +// selfAct does what the router's own ask decided. +func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) { + if rec.Act == nil || w.Outcome != asks.OutcomeChosen { + return + } + o, err := w.For(SelfAsker, rec.Ask) + if err == nil { + err = o.Performs(rec.Act.act()) + } + if err != nil { + h.Logf("[messenger] REFUSED a warrant for its own ask %s: %v", rec.Ask.ID, err) + return + } + switch rec.Act.Do { + case "unlink": + if o.Level == asks.Acknowledge { + // Asked at the desk: only an account still in its first hour, which cannot approve yet, is unlinked + // this way (ADR 0259 §7, the review of 2026-10-09). Past its hour it decides for itself. + id := h.identity(rec.Act.Kind, rec.Act.Identity) + if id == nil || !h.Now().Before(id.ApproveFrom) { + h.Logf("[messenger] an unlink chosen at the desk was not done: the account's first hour has passed") + h.announce(Message{Title: "An unlink chosen at the desk was not done", + Body: "The account's first hour had passed, so only the account itself, or root at the router's " + + "machine, can unlink it now.", Urgent: true}) + return + } + h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "at the desk, within the hour after it was linked", true) + return + } + h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "approved by the account itself", false) + case "link": + if rec.Act.Link != nil && rec.Act.Link.Identity == rec.Act.Identity { + h.linkNow(*rec.Act.Link, "approved by the account it replaces") + } + } +} + +// Unlink asks the account linked on a kind whether it is to be unlinked: an approve ask put to it alone. +func (h *Holder) Unlink(kind string) (map[string]any, error) { + h.warmRootFree(false) // outside the work: a question it asks reads a recent word + h.work.Lock() + defer h.work.Unlock() + ids := h.identitiesOf(kind) + if len(ids) == 0 { + return map[string]any{"kind": kind, "linked": false, "asked": false}, nil + } + id := ids[0] + now := h.Now() + a := asks.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?", + Explanation: fmt.Sprintf("Unlinking this %s account (%s, number %s) was asked. If you choose Unlink, answers "+ + "from here stop counting as yours until an account is linked again.", Display(kind), id.Display, id.Identity), + Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the account stays linked", + Options: []asks.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: asks.Approve}}} + if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind, Identity: id.Identity}); err != nil { + return nil, err + } + h.announceExcept(kind, Message{Title: "An unlink of your " + Display(kind) + " account was asked", + Body: fmt.Sprintf("It waits for the account itself (%s, number %s) to approve it within the hour. If you "+ + "cannot answer there, you unlink it as root at the router's machine.", id.Display, id.Identity), Urgent: true}) + return map[string]any{"kind": kind, "asked": true, "of": id.Display}, nil +} + +// unlinkNow unlinks one account of a kind: told first, by name, then every channel. It unlinks the account it +// is named, and nothing when another is linked now. +func (h *Holder) unlinkNow(kind, identity, how string, early bool) { + if h.Asks == nil { + return + } + id := h.identity(kind, identity) + if id == nil { + h.Logf("[messenger] the unlink of %s on %s was not done: that account is not linked", identity, kind) + return + } + _ = h.showOn(kind, Message{Title: "This account is no longer linked as you", + Body: "Unlinked, " + how + ". Answers from here no longer count as yours.", Urgent: true, To: id.Identity}) + if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil { + h.Logf("[messenger] the unlink of %s on %s could not be recorded: %v", id.Display, kind, err) + return + } + h.unlinked(kind, Unlinked{Identity: *id, At: h.Now(), Early: early}) + after := "A new link within a day waits for that account's approval, and that account itself can be linked " + + "again only after a day." + if early { + after = "Any account, this one too, can be linked again at once with a code shown on your desk." + } + h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more", + Body: fmt.Sprintf("Unlinked: %s (number %s), %s. %s", id.Display, id.Identity, how, after), Urgent: true}) + h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: id.Display + ", " + how}) +} + +// linkNow links an account as the operator's: acknowledges at once, approves after the hour. +func (h *Holder) linkNow(id Identity, how string) { + if h.Asks == nil { + return + } + // One account per kind, checked again as it is linked: a link approved, or allowed by root, while + // another account was linked meanwhile is not made (the review of 2026-10-09). + for _, other := range h.identitiesOf(id.Kind) { + if other.Identity != id.Identity { + h.Logf("[messenger] the link of %s on %s was not made: %s is linked there", id.Display, id.Kind, other.Display) + h.announce(Message{Title: "A " + Display(id.Kind) + " link was not made", + Body: fmt.Sprintf("%s (number %s) was not linked, %s: the account %s (number %s) is linked there now. "+ + "One account is linked per channel.", id.Display, id.Identity, how, other.Display, other.Identity), + Urgent: true}) + return + } + return // already linked + } + now := h.Now() + id.Linked, id.ApproveFrom = now, now.Add(LinkedApproveAfter) + if err := h.Asks.PutIdentity(identityKey(id.Kind, id.Identity), id); err != nil { + h.Logf("[messenger] the link of %s on %s could not be recorded: %v", id.Display, id.Kind, err) + return + } + _ = h.Asks.DeleteLink(id.Kind) + from := id.ApproveFrom.In(h.zone()).Format("15:04") + h.announce(Message{Title: "A " + Display(id.Kind) + " account is now linked as you", + Body: fmt.Sprintf("The %s account %s (number %s) is linked, %s. It can answer acknowledgements now, and "+ + "approve from %s. If this was not you, unlink it before then.", Display(id.Kind), id.Display, id.Identity, how, from), + Urgent: true}) + h.record(Sent{At: now, Channel: id.Kind, Key: "(link)", What: "linked " + id.Display, Outcome: how}) +} + +// breakGlass does what root asked at the router's machine: a file `unlink.` or `allow-link.` in +// the router's directory, owned by root. Called every minute from Tick, which holds the work: it takes no +// lock of its own (the review of 2026-10-09 found it took the work again, and so hung the router's minute +// the first time root used it). Loud: what root did, or why nothing was done, is said on every channel, and +// every open question of the router's own ends, since root decided in its place. +func (h *Holder) breakGlass() { + if h.StateDir == "" { + return + } + for _, pattern := range []string{"unlink.*", "allow-link.*"} { + files, _ := filepath.Glob(filepath.Join(h.StateDir, pattern)) + for _, f := range files { + name := filepath.Base(f) + what, kind, _ := strings.Cut(name, ".") + info, err := os.Lstat(f) + if err != nil { + continue + } + owner := fileOwner(info) + _ = os.Remove(f) + if !info.Mode().IsRegular() || owner != 0 { + h.Logf("[messenger] %s was refused: it is not a file root made", name) + h.announce(Message{Title: "A request to change who is you was refused", + Body: "A file asking it was left at the router's machine by someone other than root. Nothing changed.", + Urgent: true}) + continue + } + where := "at the router's machine" + if h.Node != "" { + where = "at " + h.Node + } + done := "" + switch what { + case "unlink": + if ids := h.identitiesOf(kind); len(ids) > 0 { + h.unlinkNow(kind, ids[0].Identity, where+", as root", false) + done = fmt.Sprintf("the %s account %s (number %s) was unlinked", Display(kind), ids[0].Display, ids[0].Identity) + } else { + done = "nothing was unlinked: no " + Display(kind) + " account is linked" + } + case "allow-link": + if p, err := h.Asks.GetLink(kind); err == nil && p != nil && p.Confirmed { + h.linkNow(Identity{Kind: kind, Identity: p.Identity, Display: p.Display}, "allowed "+where+", as root") + done = fmt.Sprintf("the waiting link of %s (number %s) was allowed", p.Display, p.Identity) + } else { + done = "nothing was linked: no link with its code given waits on " + Display(kind) + } + // And whatever bar an unlink set is lifted: root decided who may be linked. + if h.recentUnlink(kind) != nil { + h.liftBar(kind) + done += "; the bar on linking an account again after an unlink was lifted" + } + } + ended := h.endSelfAsks("root decided " + where) + body := "Root " + where + " asked it: " + done + "." + if ended > 0 { + body += fmt.Sprintf(" %d open question(s) of the router's own were ended.", ended) + } + h.Logf("[messenger] break-glass %s: %s", name, done) + h.announce(Message{Title: "Root changed who is you", Body: body, Urgent: true}) + } + } +} + +// endSelfAsks ends every open ask of the router's own: root decided in their place. +func (h *Holder) endSelfAsks(why string) int { + h.mu.Lock() + var keys []string + for k, e := range h.asks { + if e.rec.Asker == SelfAsker && e.rec.State == StateOpen { + keys = append(keys, k) + } + } + h.mu.Unlock() + sort.Strings(keys) + n := 0 + for _, k := range keys { + if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, ""); err == nil { + n++ + } + } + return n +} + +// fileOwner is the uid owning a file; a variable so a test, which is not root, can stand for root. +var fileOwner = func(info os.FileInfo) uint32 { + if st, ok := info.Sys().(*syscall.Stat_t); ok { + return st.Uid + } + return 1 +} + +// unlinked keeps the last unlink of a kind, a day. +func (h *Holder) unlinked(kind string, u Unlinked) { + h.mu.Lock() + defer h.mu.Unlock() + if h.lastUnlink == nil { + h.lastUnlink = map[string]Unlinked{} + } + h.lastUnlink[kind] = u + if h.Asks != nil { + _ = h.Asks.PutUnlinked(kind, u) + } +} + +// liftBar forgets the last unlink of a kind: root's allow-link at the router's machine. +func (h *Holder) liftBar(kind string) { + h.mu.Lock() + delete(h.lastUnlink, kind) + h.mu.Unlock() + if h.Asks != nil { + _ = h.Asks.PutUnlinked(kind, Unlinked{}) + } +} + +// recentUnlink is the account unlinked deliberately on a kind less than a day ago, if any: an early desk +// unlink bars nothing. +func (h *Holder) recentUnlink(kind string) *Unlinked { + h.mu.Lock() + u, ok := h.lastUnlink[kind] + h.mu.Unlock() + if !ok && h.Asks != nil { + if got, err := h.Asks.GetUnlinked(kind); err == nil && got != nil { + u, ok = *got, true + } + } + if !ok || u.Early || u.At.IsZero() || h.Now().Sub(u.At) >= RelinkWaits { + return nil + } + return &u +} + +// announceExcept says a message on every kind that can send but one. +func (h *Holder) announceExcept(kind string, m Message) { + if h.Bench == nil { + return + } + for _, k := range h.Bench.Kinds() { + if k.Kind != kind && h.Bench.Ready(k.Kind) == nil { + _ = h.showOn(k.Kind, m) + } + } +} 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..32d2e474 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") } } @@ -324,3 +314,34 @@ func TestTimesAreSaidInTheOperatorsZone(t *testing.T) { t.Errorf("a zone that does not exist was not said: %v", err) } } + +// A warning kept quiet is said once after a day, and not again (novox/hq ADR 0258 §6). +func TestAQuietWarningIsSaidOnceAfterADay(t *testing.T) { + h, tg, dt, c, _ := newHolder(t) + c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC) + e := examples[0] + raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false)) + h.Condition(EventRaised, raised) + settle(h, c) + if len(dt.sends)+len(tg.sends) != 0 { + t.Fatal("said while it needed nothing") + } + c.pass(QuietRemindAfter - time.Hour) + h.Tick() + settle(h, c) + if len(dt.sends)+len(tg.sends) != 0 { + t.Fatal("said before a day") + } + c.pass(time.Hour) + h.Tick() + settle(h, c) + if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Still open after a day: ") { + t.Fatalf("not said once after a day: %+v", dt.sends) + } + c.pass(QuietRemindAfter) + h.Tick() + settle(h, c) + if len(dt.sends) != 1 { + t.Errorf("said again: %d", len(dt.sends)) + } +} diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index f6a1541c..b00bc100 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.10 diff --git a/modules/messenger/go.sum b/modules/messenger/go.sum index b474419e..8446865c 100644 --- a/modules/messenger/go.sum +++ b/modules/messenger/go.sum @@ -1,2 +1,2 @@ -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.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o= +git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 7801db20..aa8bd5d8 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -2,6 +2,7 @@ "module": "messenger", "version": "1", "slug": "msgr", + "runs-as": "messenger", "seats": [ { "name": "operator-channel", @@ -9,7 +10,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,43 +61,65 @@ "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" + "seat:mesh-controller.conditions", + "seat:mesh-controller.root-free" ], "state": [ "open", - "sent" + "sent", + { + "name": "asks", + "ttl-seconds": 2592000 + }, + "identities" ], "own-secrets": { - "telegram-token": "${dir:state}/telegram-token" + "broker": "${dir:state}/broker" }, + "secrets-owner": "messenger", "tools": [ "messenger_status", "messenger_recent", "messenger_test", - "messenger_check" + "messenger_check", + "messenger_identities", + "messenger_unlink" ], "resources": [ + { + "id": "account", + "type": "user", + "name": "messenger", + "shell": "/usr/bin/nologin", + "home": "/var/lib/messenger" + }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "owner": "messenger" }, { "id": "settings", @@ -64,7 +127,8 @@ "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", + "owner": "messenger" } ], "build": { @@ -81,7 +145,7 @@ ], "env": { "MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json", - "MESH_MESSENGER_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token" + "MESH_MESSENGER_STATE": "${dir:state}" } } ] diff --git a/modules/sudo/.gitignore b/modules/sudo/.gitignore new file mode 100644 index 00000000..3b1a6054 --- /dev/null +++ b/modules/sudo/.gitignore @@ -0,0 +1,2 @@ +# The bundle built here (manifest build.artifacts[0].binary): never committed. +/sudo-tools diff --git a/modules/sudo/cmd/sudo-tools/escalation.go b/modules/sudo/cmd/sudo-tools/escalation.go new file mode 100644 index 00000000..dc8a1734 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/escalation.go @@ -0,0 +1,122 @@ +package main + +// Who can become root here without a person (novox/hq ADR 0259 §8): the controller's self-check may ask it for +// the accounts agents run as and the account the machine's runtime runs commands as. +// +// **Conservative, as the review of 2026-10-09 asked.** An account can become root without a person when: +// - it is root; +// - it is a member of a group that is root by another name (docker, disk, root, lxd, incus-admin, libvirt); +// - **sudo lists any rule for it at all** — with NOPASSWD or without, for every command or for one. A named +// command is root when it can start a shell or write a file (systemctl, tee, bash, pacman, an editor, …), +// and no list of safe commands is kept: none is proven safe here. A rule that asks a password is root too, +// because sudo keeps a password typed by the person for minutes, during which a program of the same +// account needs none. This is the node-engine's own rule for an agent account (ADR 0266: "any sudo rule"). +// +// **What cannot be judged is an error, never a no**: an account this machine does not know, sudo that could not +// be asked, a listing that cannot be read. The caller says it was not measured. +// +// **Measured as the runtime's account**, which these tools run as: an agent that could act as that account +// could change this answer, so the controller takes the agent account's verdict from the node-engine, which +// runs as root (ADR 0266), and this tool is a reading for a person, not that verdict. + +import ( + "fmt" + "regexp" + "strings" +) + +// Escalation is one account's answer. +type Escalation struct { + Account string `json:"account"` + Root bool `json:"root_without_a_person"` + Why string `json:"why"` +} + +// rootGroups are groups that are root by another name. +var rootGroups = map[string]string{"docker": "the docker group, which can mount the machine's root file system", + "disk": "the disk group, which reads and writes the drives raw", "root": "the root group", + "lxd": "the lxd group, which runs privileged containers", "incus-admin": "the incus-admin group, which runs privileged containers", + "libvirt": "the libvirt group, which runs machines with the host's disks"} + +var accountShape = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`) + +// Escalations answers, for each account, whether it can become root here without a person. An account that +// cannot be judged fails the whole answer: never a no. +func (m *Machine) Escalations(accounts []string) ([]Escalation, error) { + if len(accounts) == 0 { + return nil, fmt.Errorf("name the accounts to judge") + } + var out []Escalation + for _, a := range accounts { + if !accountShape.MatchString(a) { + return nil, fmt.Errorf("%q is not an account name", a) + } + e, err := m.escalation(a) + if err != nil { + return nil, fmt.Errorf("%s cannot be judged, so nothing is said of it: %v", a, err) + } + out = append(out, e) + } + return out, nil +} + +func (m *Machine) escalation(account string) (Escalation, error) { + e := Escalation{Account: account} + if account == "root" { + e.Root, e.Why = true, "it is root" + return e, nil + } + id := m.Run(bg(), "id", "-nG", account) + if id.Status != 0 || id.Err != "" { + return e, fmt.Errorf("this machine does not know the account: %s", strings.TrimSpace(firstLine(id.Stderr+id.Err))) + } + for _, g := range strings.Fields(id.Stdout) { + if why, ok := rootGroups[g]; ok { + e.Root, e.Why = true, "a member of "+why + return e, nil + } + } + // sudo's own word, for that account: listing another account's rules needs root, which the runtime's + // account escalates to as the mesh's acting tools do. + r := m.Run(bg(), "sudo", "-n", "-l", "-U", account) + listing := r.Stdout + r.Stderr + if strings.Contains(listing, "is not allowed to run sudo") { + e.Why = "no sudo rule names it, and no group it is in is root by another name" + return e, nil + } + if r.Status != 0 || r.Err != "" { + return e, fmt.Errorf("sudo could not be asked: %s", strings.TrimSpace(firstLine(r.Stderr+r.Err))) + } + rules := ParseList(r.Stdout, account) + if !mayRun.MatchString(firstMayRun(r.Stdout)) { + return e, fmt.Errorf("sudo's listing could not be read") + } + if len(rules.Rules) == 0 { + e.Why = "sudo lists no rule for it, and no group it is in is root by another name" + return e, nil + } + var lines []string + for _, rule := range rules.Rules { + lines = append(lines, rule.Line) + } + how := "a password the person typed is kept by sudo for minutes" + for _, rule := range rules.Rules { + if hasTag(rule.Tags, "NOPASSWD") { + how = "with no password" + break + } + } + e.Root, e.Why = true, "sudo has a rule for it ("+strings.Join(lines, "; ")+"), "+how+ + ": any rule counts, since no command is proven unable to reach root" + return e, nil +} + +// firstMayRun is the listing's "User … may run the following commands on …:" line, or "". +func firstMayRun(out string) string { + for _, l := range strings.Split(out, "\n") { + if l = strings.TrimSpace(l); mayRun.MatchString(l) { + return l + } + } + return "" +} diff --git a/modules/sudo/cmd/sudo-tools/escalation_test.go b/modules/sudo/cmd/sudo-tools/escalation_test.go new file mode 100644 index 00000000..69b89726 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/escalation_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "strings" + "testing" +) + +// novox/hq ADR 0259 §8, as the review of 2026-10-09 asked: any sudo rule is root without a person — NOPASSWD on +// a named command (systemctl, tee, bash, pacman) as much as on all of them, and a rule asking a password too — +// and what cannot be judged is an error, never a no. +func TestWhoCanBecomeRootWithoutAPerson(t *testing.T) { + var calls []call + listing := func(account, rule string) Ran { + return Ran{Stdout: "User " + account + " may run the following commands on anchor:\n " + rule + "\n"} + } + run := byLine(map[string]Ran{ + "id -nG ops": {Stdout: "ops wheel\n"}, + "sudo -n -l -U ops": listing("ops", "(ALL : ALL) NOPASSWD: ALL"), + "id -nG agents": {Stdout: "agents\n"}, + "sudo -n -l -U agents": {Stdout: "User agents is not allowed to run sudo on anchor.\n", Status: 1}, + "id -nG builder": {Stdout: "builder docker\n"}, + "id -nG careful": {Stdout: "careful wheel\n"}, + "sudo -n -l -U careful": listing("careful", "(ALL : ALL) ALL"), + "id -nG units": {Stdout: "units\n"}, + "sudo -n -l -U units": listing("units", "(root) NOPASSWD: /usr/bin/systemctl"), + "id -nG writer": {Stdout: "writer\n"}, + "sudo -n -l -U writer": listing("writer", "(root) NOPASSWD: /usr/bin/tee /etc/hosts"), + "id -nG packager": {Stdout: "packager\n"}, + "sudo -n -l -U packager": listing("packager", "(root) NOPASSWD: /usr/bin/pacman -Syu"), + "id -nG asother": {Stdout: "asother\n"}, + "sudo -n -l -U asother": listing("asother", "(ops) NOPASSWD: /bin/bash"), + "id -nG containers": {Stdout: "containers lxd\n"}, + }, &calls) + got, err := machine(run, 1000).Escalations([]string{"ops", "agents", "builder", "careful", "units", "writer", + "packager", "asother", "containers", "root"}) + if err != nil { + t.Fatal(err) + } + want := map[string]bool{"ops": true, "agents": false, "builder": true, "careful": true, "units": true, "writer": true, + "packager": true, "asother": true, "containers": true, "root": true} + for _, e := range got { + if e.Root != want[e.Account] || e.Why == "" { + t.Errorf("%s: root %v (%s), want %v", e.Account, e.Root, e.Why, want[e.Account]) + } + } + if _, err := machine(run, 1000).Escalations([]string{"a b"}); err == nil || !strings.Contains(err.Error(), "not an account name") { + t.Errorf("an account name that is not one was asked: %v", err) + } +} + +// What cannot be judged is never a no: an unknown account, sudo that cannot be asked, a listing that cannot be +// read — each fails the whole answer. +func TestWhatCannotBeJudgedIsAnError(t *testing.T) { + var calls []call + run := byLine(map[string]Ran{ + "id -nG ghost": {Status: 1, Stderr: "id: 'ghost': no such user"}, + "id -nG agents": {Stdout: "agents\n"}, + "sudo -n -l -U agents": {Status: 1, Stderr: "sudo: a password is required"}, + "id -nG garbled": {Stdout: "garbled\n"}, + "sudo -n -l -U garbled": {Stdout: "something else entirely\n"}, + "id -nG fine": {Stdout: "fine\n"}, + "sudo -n -l -U fine": {Stdout: "User fine is not allowed to run sudo on anchor.\n", Status: 1}, + }, &calls) + for _, a := range []string{"ghost", "agents", "garbled"} { + got, err := machine(run, 1000).Escalations([]string{"fine", a}) + if err == nil || !strings.Contains(err.Error(), a+" cannot be judged") { + t.Errorf("%s: answered %+v (%v), want an error", a, got, err) + } + } +} diff --git a/modules/sudo/cmd/sudo-tools/main.go b/modules/sudo/cmd/sudo-tools/main.go index 34217b98..0c135774 100644 --- a/modules/sudo/cmd/sudo-tools/main.go +++ b/modules/sudo/cmd/sudo-tools/main.go @@ -44,6 +44,27 @@ func tools(m *Machine) []stdio.Tool { Input: schema(map[string]any{}), Run: func(map[string]any) (any, error) { return m.CheckEscalation() }, }, + { + Name: "sudo_escalation", + Description: "Can these accounts become root on this machine without a person: any sudo rule for it " + + "(with or without a password, for any command), or a group that is root by another name (docker, disk, " + + "root, lxd, incus-admin, libvirt). Each account with yes or no and why; an account that cannot be " + + "judged fails the answer, never a no. Read as the runtime's account: a reading for a person — the " + + "agent account's verdict is the node-engine's (novox/hq ADR 0259 §8, ADR 0266).", + Input: schema(map[string]any{"accounts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "the accounts to judge"}}), + Run: func(a map[string]any) (any, error) { + var accounts []string + if list, ok := a["accounts"].([]any); ok { + for _, x := range list { + if s, ok := x.(string); ok { + accounts = append(accounts, s) + } + } + } + return m.Escalations(accounts) + }, + }, { Name: "sudo_drop_ins", Description: "The files of /etc/sudoers.d with owner, mode and size, whether sudo reads each (a name with a dot " + diff --git a/modules/sudo/module.json b/modules/sudo/module.json index a9a5fc93..4ae66d64 100644 --- a/modules/sudo/module.json +++ b/modules/sudo/module.json @@ -7,6 +7,7 @@ "tools": [ "sudo_rules", "sudo_check", + "sudo_escalation", "sudo_drop_ins" ], "resources": [ diff --git a/modules/telegram/README.md b/modules/telegram/README.md new file mode 100644 index 00000000..20846196 --- /dev/null +++ b/modules/telegram/README.md @@ -0,0 +1,92 @@ +# telegram + +A channel of the operator's conversation: kind `telegram` of the `channel` and `intake` benches the router +(`messenger`) declares (novox/hq ADR 0234, ADR 0259). It reaches the operator away from the machines, and it +proves who answered: Telegram authenticates the sender, and the router compares it with the account linked +as the operator. + +- **Shows what the router sends** (its kind's worker on the `channel` queue): a message, a question with + buttons — each button carries only the router's one-time ticket — or an edit of a message shown before. + It sends to the account the router names as linked, or, before one is, to the chat a `/start` came from. +- **Says what the operator chose** on `intake` under its own kind: a tapped button (`choice`), with the + sender as Telegram authenticated it; `/start` (`link`); a message it could not deliver (`failed`); and + whether it can send (`standing`). Only a private chat with the account itself counts. +- **A code** (six digits, the one shown on the desk when linking) is asked of the router as a proof, by + request and reply: never in an event, never kept, and deleted from the chat at once. +- **Judges nothing.** Whether the sender is the operator, and what an answer may do, is the router's. + +## The defects of the client it replaces (novox/hq to-be 46 §12) + +| | fixed here | +|---|---| +| D1 | up to 4096 characters arrive whole; longer is cut and the cut said | +| D4 | a warning or a clearing arrives without sound; an urgent message or a question rings | +| D5 | a rate limit is waited out as told | +| D6 | a refusal is a permanent failure, said to the router and not retried; an edit to the same text is a success | +| D7 | the deprecated preview flag is gone | +| D8 | a request that cannot be written is said | +| D10 | `standing` checks the token with Telegram (`getMe`), not only that a value is present | + +D2 and D3 (a reopening said by an edit; a clearing edits the first message) are the router's, and D9 the +watcher's. + +## How it keeps the token from the agents (novox/hq ADR 0259 §8, as revised) + +- **It runs as the account `telegram`, in a runtime of its own, on a bus account of its own** (`runs-as`). The + machine's runtime runs as the operator's account — which every agent runs as — and never carries it; the + controller refuses to compose it there. +- **Its state directory, its bot token and its bus credential belong to `telegram`**, 0700 and 0600 + (`secrets-owner`). The operator's account reads none of them, and cannot attach to its process. +- **Only this kind's holder says this kind's choices, links and codes**: the bus grants those subjects to its + own account alone. +- **The router believes it proves its sender only so placed**, and only while the controller's self-check + finds no agent able to become root without a person on its machine or the router's (`agent-root`). +- **If an agent holds root on the machine, none of this holds**: root reads the token and every credential. +- **A tap counts only on a message showing the words it put there.** It keeps a digest of what each message + shows; a tap on one whose words differ — changed by another holder of the token — or on one it never showed + is refused on the phone and never said to the router. + +## Where an approval from Telegram means something (a known gap, accepted by the operator on 2026-10-09) + +The router does not measure or enforce what follows, and does not hold approvals back for it: the operator +accepted the gap for now, and novox/hq issue 344 records it with what closes it. + +An approval from Telegram proves only that the linked account tapped. It is meaningful where nothing working +for the operator can tap in their name, which on a machine where agents run needs all three of: + +1. **The agents run under an account of their own, without sudo** (novox/hq ADR 0266), so they cannot become + root or the operator. +2. **That account has no access to the operator's X display** — no Xauthority cookie for it. X11 has no + isolation between the clients of one display: a program on it reads every key typed and can click any + window, the Telegram client's buttons among them. +3. **The operator's Telegram Desktop data is readable by the operator alone** (`~/.local/share/TelegramDesktop/tdata`, + 0700). Found 0755 with its files 0644: any account on the machine can copy the session and be the operator + on Telegram from elsewhere. + +Until agents have their own account on a machine, the operator's Telegram client on that machine is not +counted as safe: **approve from the phone.** No module of the catalogue installs Telegram Desktop today, so +nothing of the mesh keeps `tdata` 0700 yet; the operator sets it by hand (`chmod -R go-rwx` on it) until one +does. + +## Where it runs + +Held once for the mesh: assign it to the control node, the stably reachable one. Long polling is outbound +only, so no route into the mesh is needed, and a stolen token can read updates (a conflict says so) but +never inject one. + +## What the operator gives + +1. **A bot**: in Telegram, talk to @BotFather, `/newbot`, a name and a username. BotFather answers with the + bot's token. Keep it on the phone; never paste it into a chat with an agent. +2. **The token, to the vault**, as this module's own secret `telegram-token`: the vault's verb for a secret + given at the desk, or `secret accept telegram telegram-token` at a terminal on the control + node, which reads it without echoing it. Then push the control node. +3. **The link**: send the bot `/start`. A six-digit code appears on the desk; send it to the bot within ten + minutes. The account answers acknowledgements at once and approves an hour later; every channel says so. + `messenger_identities` shows it. `messenger_unlink` asks the linked account itself whether to unlink it. + +## Tools + +| tool | does | +|---|---| +| `telegram_status` | whether it can send and why not (never the token), the bot's name, when updates last arrived, the last delivery and error, counts since start, and its rules | diff --git a/modules/telegram/cmd/telegram/bot.go b/modules/telegram/cmd/telegram/bot.go new file mode 100644 index 00000000..9a05a56a --- /dev/null +++ b/modules/telegram/cmd/telegram/bot.go @@ -0,0 +1,290 @@ +package main + +// The Bot API, as this channel uses it. The bot token is this module's own secret, read from its file at +// each use, owned by the account this module runs as and by no other (novox/hq ADR 0259 §8). It is never +// 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, and a description the API gives back has the +// token taken out. + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" +) + +// API is where the Bot API answers; a test points it elsewhere. +var API = "https://api.telegram.org" + +var tokenShape = regexp.MustCompile(`^\d{5,}:[A-Za-z0-9_-]{30,}$`) + +// Bot calls the Bot API with the token in its file. +type Bot struct { + TokenFile string + Client *http.Client +} + +// apiError is the API's refusal: its code, its words, and how long it asks to be left alone. +type apiError struct { + Method string + Code int + Words string + RetryAfter time.Duration +} + +func (e *apiError) Error() string { + return fmt.Sprintf("telegram %s refused: %d %s", e.Method, e.Code, e.Words) +} + +// permanent says whether trying again cannot help: a refusal of the request itself (400, 403) or of the +// token (401, 404), never a rate limit or the service failing (D6). +func permanent(err error) bool { + var a *apiError + return errors.As(err, &a) && (a.Code == 400 || a.Code == 401 || a.Code == 403 || a.Code == 404) +} + +func retryAfter(err error) time.Duration { + var a *apiError + if errors.As(err, &a) && a.Code == 429 { + if a.RetryAfter > 0 { + return a.RetryAfter + } + return time.Second + } + return 0 +} + +// notModified is an edit to the text it already has: a success, never a reason to send again (D6). +func notModified(err error) bool { + var a *apiError + return errors.As(err, &a) && a.Code == 400 && strings.Contains(a.Words, "message is not modified") +} + +func gone(err error) bool { + var a *apiError + return errors.As(err, &a) && a.Code == 400 && (strings.Contains(a.Words, "message to edit not found") || + strings.Contains(a.Words, "message to delete not found")) +} + +// token reads the bot token, and says in words what is missing when it cannot: never the value. +func (b *Bot) token() (string, error) { + if b.TokenFile == "" { + return "", errors.New("no bot token: this module was started without a token file") + } + raw, err := os.ReadFile(b.TokenFile) + if errors.Is(err, os.ErrNotExist) { + return "", errors.New("no bot token: the own secret telegram-token is not on this machine yet; the operator " + + "gives it to the vault (the module's README says how)") + } + 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 makes a random value for an own secret nobody gave; 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 given; the operator gives it to the vault") + } + return token, nil +} + +// call asks the API one method, decoding its result into `into`. +func (b *Bot) call(method string, body map[string]any, into any) error { + token, err := b.token() + if err != nil { + return err + } + raw, err := json.Marshal(body) + if err != nil { + // Said, never discarded (D8). + return fmt.Errorf("telegram %s: the request could not be written: %v", method, err) + } + req, err := http.NewRequest(http.MethodPost, API+"/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") + client := b.Client + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + resp, err := 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"` + Parameters struct { + RetryAfter int `json:"retry_after"` + } `json:"parameters"` + } + 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 { + return &apiError{Method: method, Code: answer.ErrorCode, Words: strings.ReplaceAll(answer.Description, token, ""), + RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second} + } + if into != nil && len(answer.Result) > 0 { + if err := json.Unmarshal(answer.Result, into); err != nil { + return fmt.Errorf("telegram %s: its answer could not be read: %v", method, err) + } + } + 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 +} + +// User is a Telegram account as the API says it. +type User struct { + ID int64 `json:"id"` + IsBot bool `json:"is_bot"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` +} + +// Display is the name the service shows for an account. +func (u User) Display() string { + name := strings.TrimSpace(u.FirstName + " " + u.LastName) + if u.Username != "" { + if name != "" { + return name + " (@" + u.Username + ")" + } + return "@" + u.Username + } + return name +} + +// Chat is where a message was written. +type Chat struct { + ID int64 `json:"id"` + Type string `json:"type"` +} + +// Msg is a message as the API says it. +type Msg struct { + MessageID int64 `json:"message_id"` + From *User `json:"from"` + Chat Chat `json:"chat"` + Text string `json:"text"` +} + +// Callback is a button tapped. +type Callback struct { + ID string `json:"id"` + From User `json:"from"` + Message *Msg `json:"message"` + Data string `json:"data"` +} + +// Update is one thing that happened to the bot. +type Update struct { + UpdateID int64 `json:"update_id"` + Message *Msg `json:"message"` + Callback *Callback `json:"callback_query"` +} + +// Updates long-polls for what happened since offset: outbound only, so no route into the mesh is needed, and +// a stolen token can read updates (a conflict says so) but never inject one (novox/hq ADR 0234). +func (b *Bot) Updates(offset int64, wait time.Duration) ([]Update, error) { + var out []Update + err := b.call("getUpdates", map[string]any{"offset": offset, "timeout": int(wait.Seconds()), + "allowed_updates": []string{"message", "callback_query"}}, &out) + return out, err +} + +// Me is the bot's own account: the check that the token works, with nothing sent. +func (b *Bot) Me() (User, error) { + var me User + return me, b.call("getMe", map[string]any{}, &me) +} + +// keyboard is buttons one to a row, each carrying only its ticket. +func keyboard(buttons [][2]string) map[string]any { + rows := [][]map[string]string{} + for _, b := range buttons { + rows = append(rows, []map[string]string{{"text": b[0], "callback_data": b[1]}}) + } + return map[string]any{"inline_keyboard": rows} +} + +// Send posts a message and answers its id. silent: it arrives without sound (D4). +func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (int64, error) { + body := map[string]any{"chat_id": chat, "text": text, "disable_notification": silent, + "link_preview_options": map[string]any{"is_disabled": true}} + if len(buttons) > 0 { + body["reply_markup"] = keyboard(buttons) + } + var out Msg + err := b.call("sendMessage", body, &out) + return out.MessageID, err +} + +// Edit replaces a message's text and buttons; none given, the buttons go. +func (b *Bot) Edit(chat, id int64, text string, buttons [][2]string) error { + err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text, + "reply_markup": keyboard(buttons), "link_preview_options": map[string]any{"is_disabled": true}}, nil) + if notModified(err) { + return nil + } + return err +} + +// AnswerTap ends the phone's spinner for a tapped button, at once. +func (b *Bot) AnswerTap(id, text string) error { + body := map[string]any{"callback_query_id": id} + if text != "" { + body["text"] = text + } + return b.call("answerCallbackQuery", body, nil) +} + +// Delete removes a message from the chat: a code once read. +func (b *Bot) Delete(chat, id int64) error { + err := b.call("deleteMessage", map[string]any{"chat_id": chat, "message_id": id}, nil) + if gone(err) { + return nil + } + return err +} diff --git a/modules/telegram/cmd/telegram/channel.go b/modules/telegram/cmd/telegram/channel.go new file mode 100644 index 00000000..3f7fba54 --- /dev/null +++ b/modules/telegram/cmd/telegram/channel.go @@ -0,0 +1,493 @@ +package main + +// The Telegram channel: kind `telegram` of the `channel` and `intake` benches (novox/hq ADR 0259). It shows +// what the router sends and says what the operator chose, and nothing else: it never judges whether the +// sender is the operator (the router does, from its list), never calls another module's verb, and knows +// nothing of what an answer does. +// +// - **Work** comes from its kind's worker on the `channel` queue: `show` (a message with buttons, each +// carrying only the router's ticket), `send` (words), `edit` (a message shown before, by the router's +// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). A warning or a +// clearing arrives without sound, an urgent message or a question rings (D4). A rate limit is waited out +// as told (D5); a refusal is a permanent failure, said to the router, never retried; an edit to the same +// text is a success (D6). A message that cannot be delivered after three tries is said as failed. +// - **What arrives** is long-polled: outbound only. A tapped button is acknowledged at once (the phone's +// spinner stops) and said as a `choice` with the sender the service authenticated; only a private chat +// with the account itself counts, and only on a message showing the words this channel put there (another +// holder of the token may have changed them). `/start` is said as a `link`; six digits are asked as a `code` proof by +// request and reply, never in an event, and deleted from the chat. Anything else is answered with one +// fixed line. +// - **Standing** is said every five minutes and whenever it changes: whether the token works, whether +// the updates arrive, and that an edit here notifies nobody. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// Kind is this channel's kind on both benches. +const Kind = "telegram" + +const ( + // MaxLength is what a message carries whole (Telegram's own bound). + MaxLength = 4096 + // cutNote is said where a message was cut. + cutNote = "\n… (cut here: the rest is in the mesh's history)" + // Tries is how often a message is tried before it is said as failed. + Tries = 3 + // PollWait is how long one long poll waits for something to happen. + PollWait = 50 * time.Second + // StandingEvery is how often the channel says whether it can send, at least. + StandingEvery = 5 * time.Minute + // FixedLine is what anything the bot does not take is answered with. + FixedLine = "This bot carries the mesh's messages and takes their buttons. To link this account as the " + + "operator, send /start." +) + +var codeShape = regexp.MustCompile(`^\s*(\d{3})\s?(\d{3})\s*$`) + +// Placed is where one of the router's messages is in the chat. +type Placed struct { + Chat int64 `json:"chat"` + ID int64 `json:"id"` +} + +// Store is this module's state on the bus: the update offset and where each message is. +type Store interface { + Offset() (int64, error) + SetOffset(int64) error + Placed(handle string) (*Placed, error) + Place(handle string, p Placed) error + // Shown is the digest of the words a message in a chat shows as this channel last put them there (wordsDigest), + // empty for one it never showed; Show keeps it. + Shown(chat, id int64) (string, error) + Show(chat, id int64, digest string) error +} + +// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — its line +// endings and outer spaces aside — so a message whose words another holder of the token changed is told +// from the one this channel showed (the review of 2026-10-09). +func wordsDigest(words string) string { + w := strings.TrimSpace(strings.ReplaceAll(words, "\r\n", "\n")) + sum := sha256.Sum256([]byte(w)) + return hex.EncodeToString(sum[:]) +} + +// keepShown keeps what a message now shows; a message whose words could not be kept answers no tap. +func (c *Channel) keepShown(chat, id int64, words string) { + if err := c.Store.Show(chat, id, wordsDigest(words)); err != nil { + c.Logf("[telegram] what a message shows could not be kept, so its buttons will be refused: %v", err) + } +} + +// Channel is the channel's whole working. +type Channel struct { + Bot *Bot + Store Store + Publish func(subject string, body any, id string) error + Prove func(subject string, body any) (json.RawMessage, error) + Now func() time.Time + Logf func(string, ...any) + Sleep func(time.Duration) + + mu sync.Mutex + replyTo map[string]int64 // an arrival's id to the chat it came from, for the router's reply + lastPoll time.Time + pollErr string + meAt time.Time + me string + meErr string + said asks.Standing + saidAt time.Time + lastSent time.Time + lastErr string + delivered int + failed int +} + +func (c *Channel) init() { + c.mu.Lock() + defer c.mu.Unlock() + if c.replyTo == nil { + c.replyTo = map[string]int64{} + } + if c.Now == nil { + c.Now = time.Now + } + if c.Logf == nil { + c.Logf = func(string, ...any) {} + } + if c.Sleep == nil { + c.Sleep = time.Sleep + } +} + +// text is a message as the chat shows it, cut at MaxLength and the cut said (D1). +func text(m asks.Message) string { + t := m.Title + if m.Body != "" { + t += "\n" + m.Body + } + if utf8.RuneCountInString(t) <= MaxLength { + return t + } + runes := []rune(t) + keep := MaxLength - utf8.RuneCountInString(cutNote) + return string(runes[:keep]) + cutNote +} + +// buttons are a message's buttons as the API takes them: a label and the ticket, which must fit the 64 +// bytes Telegram allows; one that does not is left out and said. +func (c *Channel) buttons(m asks.Message) [][2]string { + var out [][2]string + for _, b := range m.Buttons { + if len(b.Ticket) == 0 || len(b.Ticket) > 64 { + c.Logf("[telegram] a button %q carries a ticket Telegram cannot hold; left out", b.Label) + continue + } + out = append(out, [2]string{b.Label, b.Ticket}) + } + return out +} + +// chatFor is where a message goes: the linked account the router names, else the chat of the arrival it +// replies to. Neither is a permanent failure: nobody is linked, and nobody asked. +func (c *Channel) chatFor(m asks.Message) (int64, error) { + if m.To != "" { + id, err := strconv.ParseInt(m.To, 10, 64) + if err != nil { + return 0, fmt.Errorf("the router names %q as the account, which is not a Telegram account's id", m.To) + } + return id, nil + } + c.mu.Lock() + chat, ok := c.replyTo[m.Reply] + c.mu.Unlock() + if m.Reply != "" && ok { + return chat, nil + } + return 0, errors.New("no chat to send to: no account is linked as the operator on Telegram") +} + +// Take does one piece of the channel's work. It returns an error only to have the work offered again: +// a message delivered, or said as failed, is taken. +func (c *Channel) Take(subject string, body json.RawMessage) error { + c.init() + parts := strings.Split(subject, ".") + if len(parts) != 6 || parts[5] != Kind { + c.Logf("[telegram] work on %s is not this kind's; taken and dropped", subject) + return nil + } + verb := parts[4] + var m asks.Message + if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" { + c.Logf("[telegram] work on %s could not be read; taken and dropped", subject) + return nil + } + chat, err := c.chatFor(m) + if err != nil { + c.fail(m.Handle, err, true) + return nil + } + words, buttons := text(m), c.buttons(m) + silent := m.Silent || (!m.Urgent && len(buttons) == 0) + var place *Placed + if verb == asks.Edit { + if place, err = c.Store.Placed(m.Handle); err != nil { + return err // the state is away: offered again + } + } + for try := 1; ; try++ { + var id int64 + switch { + case place != nil: + err = c.Bot.Edit(place.Chat, place.ID, words, buttons) + if err == nil || notModified(err) { + c.keepShown(place.Chat, place.ID, words) + } + if gone(err) { + place, err = nil, c.newMessage(m.Handle, chat, words, buttons, silent, &id) + } + default: + err = c.newMessage(m.Handle, chat, words, buttons, silent, &id) + } + if err == nil { + c.mu.Lock() + c.lastSent, c.lastErr = c.Now(), "" + c.delivered++ + c.mu.Unlock() + return nil + } + if wait := retryAfter(err); wait > 0 && try < Tries { + c.Sleep(min(wait, 30*time.Second)) + continue + } + if permanent(err) { + c.fail(m.Handle, err, true) + return nil + } + if try >= Tries { + c.fail(m.Handle, err, false) + return nil + } + c.Sleep(time.Duration(try*try) * time.Second) + } +} + +func (c *Channel) newMessage(handle string, chat int64, words string, buttons [][2]string, silent bool, id *int64) error { + got, err := c.Bot.Send(chat, words, buttons, silent) + if err != nil { + return err + } + *id = got + c.keepShown(chat, got, words) + if err := c.Store.Place(handle, Placed{Chat: chat, ID: got}); err != nil { + c.Logf("[telegram] a message was sent and where it is could not be kept: an edit of it will be a new message: %v", err) + } + return nil +} + +// fail says to the router that a message did not arrive, and why. +func (c *Channel) fail(handle string, err error, forGood bool) { + c.mu.Lock() + c.lastErr = err.Error() + c.failed++ + c.mu.Unlock() + c.Logf("[telegram] a message was not delivered: %v", err) + f := asks.Failed{Handle: handle, Why: err.Error(), Permanent: forGood, At: c.Now()} + if perr := c.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+handle+"."+strconv.FormatInt(c.Now().UnixNano(), 10)); perr != nil { + c.Logf("[telegram] and the router could not be told: %v", perr) + } +} + +// private says whether an arrival is from an account in its own private chat with the bot: anything in a +// group, or from another bot, is not the operator's own, whoever the router would say they are. +func private(from *User, chat Chat) bool { + return from != nil && !from.IsBot && chat.Type == "private" && chat.ID == from.ID +} + +func senderOf(u User) asks.Sender { + return asks.Sender{Identity: strconv.FormatInt(u.ID, 10), Display: u.Display(), Authenticated: true} +} + +// Handle takes one update. +func (c *Channel) Handle(u Update) { + c.init() + now := c.Now() + switch { + case u.Callback != nil: + cb := u.Callback + if cb.Message == nil || !private(&cb.From, cb.Message.Chat) { + c.answerTap(cb.ID, "") + c.Logf("[telegram] a tap outside a private chat was dropped") + return + } + // Only on a message showing exactly what this channel put there: one whose words another holder of + // the token changed, or one it never showed, answers nothing (the review of 2026-10-09). + if want, err := c.Store.Shown(cb.Message.Chat.ID, cb.Message.MessageID); err != nil || want == "" || + want != wordsDigest(cb.Message.Text) { + c.answerTap(cb.ID, "That answer was not taken: this message does not show what the mesh put there. Nothing was done.") + c.Logf("[telegram] a tap on a message not showing what this channel put there was refused") + return + } + // At once, whatever comes of it: the phone shows a spinner until then. + c.answerTap(cb.ID, "") + id := "tg-tap-" + cb.ID + c.remember(id, cb.Message.Chat.ID) + ch := asks.Choice{ID: id, Ticket: cb.Data, Sender: senderOf(cb.From), At: now} + if err := c.Publish(asks.IntakeSubject(asks.Chosen, Kind), ch, id); err != nil { + c.Logf("[telegram] a tap could not be said to the router: %v", err) + _ = c.Bot.AnswerTap(cb.ID, "The mesh could not take this answer now. Try again in a minute.") + } + case u.Message != nil: + m := u.Message + if !private(m.From, m.Chat) { + return // a group, a channel post, another bot: nothing here is the operator's own + } + id := fmt.Sprintf("tg-msg-%d", u.UpdateID) + c.remember(id, m.Chat.ID) + switch t := strings.TrimSpace(m.Text); { + case t == "/start" || strings.HasPrefix(t, "/start "): + l := asks.LinkAsked{ID: id, Sender: senderOf(*m.From), At: now} + if err := c.Publish(asks.IntakeSubject(asks.Link, Kind), l, id); err != nil { + c.reply(m.Chat.ID, "The mesh could not take this now. Try again in a minute.") + } + case codeShape.MatchString(t): + code := codeShape.ReplaceAllString(t, "$1$2") + // Out of the chat as soon as it is read (ADR 0234: deleted where the service allows). + if err := c.Bot.Delete(m.Chat.ID, m.MessageID); err != nil { + c.Logf("[telegram] a code could not be deleted from the chat: %v", err) + } + raw, err := c.Prove(asks.ProofSubject(asks.CodeProof, Kind), asks.Code{Sender: senderOf(*m.From), Code: code, Purpose: "link"}) + if err != nil { + c.reply(m.Chat.ID, "The mesh could not check the code now. Try again in a minute.") + return + } + var a asks.ProofAnswer + if json.Unmarshal(raw, &a) != nil || a.Words == "" { + a.Words = "The mesh's answer could not be read." + } + c.reply(m.Chat.ID, a.Words) + default: + c.reply(m.Chat.ID, FixedLine) + } + } +} + +func (c *Channel) answerTap(id, words string) { + if err := c.Bot.AnswerTap(id, words); err != nil { + c.Logf("[telegram] a tap could not be answered: %v", err) + } +} + +func (c *Channel) remember(id string, chat int64) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.replyTo) > 500 { + c.replyTo = map[string]int64{} + } + c.replyTo[id] = chat +} + +func (c *Channel) reply(chat int64, words string) { + if _, err := c.Bot.Send(chat, words, nil, true); err != nil { + c.Logf("[telegram] could not answer in the chat: %v", err) + } +} + +// Poll asks for what happened once, and takes it. A conflict is another program taking this bot's +// updates: a webhook set, or the token in other hands. +func (c *Channel) Poll(wait time.Duration) error { + c.init() + offset, err := c.Store.Offset() + if err != nil { + return err + } + updates, err := c.Bot.Updates(offset, wait) + c.mu.Lock() + if err != nil { + var a *apiError + if errors.As(err, &a) && a.Code == 409 { + err = errors.New("another program takes this bot's updates (a webhook, or the token in other hands): " + a.Words) + } + c.pollErr = err.Error() + } else { + c.pollErr, c.lastPoll = "", c.Now() + } + c.mu.Unlock() + if err != nil { + return err + } + for _, u := range updates { + c.Handle(u) + if u.UpdateID >= offset { + offset = u.UpdateID + 1 + } + // Kept after each update, not after the batch: a restart in the middle takes again only the one it + // was handling (the review of 2026-10-08), and the router acts on an answer once by its id anyway. + if err := c.Store.SetOffset(offset); err != nil { + c.Logf("[telegram] the update offset could not be kept; a restart may take some updates again: %v", err) + } + } + return nil +} + +// Standing is whether the channel can send now: the token works and the updates arrive. +func (c *Channel) Standing() asks.Standing { + c.init() + now := c.Now() + s := asks.Standing{EditsSilently: true, At: now} + if _, err := c.Bot.token(); err != nil { + s.Why = err.Error() + return s + } + c.mu.Lock() + stale := now.Sub(c.meAt) >= StandingEvery + c.mu.Unlock() + if stale { + me, err := c.Bot.Me() + c.mu.Lock() + c.meAt = now + c.me, c.meErr = me.Username, "" + if err != nil { + c.meErr = err.Error() + } + c.mu.Unlock() + } + c.mu.Lock() + defer c.mu.Unlock() + switch { + case c.meErr != "": + s.Why = "the bot token does not work: " + c.meErr + case c.pollErr != "": + s.Why = "the updates do not arrive: " + c.pollErr + default: + s.Ready = true + } + return s +} + +// SayStanding says the standing when it changed, or every StandingEvery. +func (c *Channel) SayStanding() { + s := c.Standing() + c.mu.Lock() + same := s.Ready == c.said.Ready && s.Why == c.said.Why && s.At.Sub(c.saidAt) < StandingEvery + c.mu.Unlock() + if same { + return + } + if err := c.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil { + c.Logf("[telegram] could not say whether it can send: %v", err) + return + } + c.mu.Lock() + c.said, c.saidAt = s, s.At + c.mu.Unlock() +} + +// Status is the channel's account of itself, for the tool. +func (c *Channel) Status() map[string]any { + s := c.Standing() + c.mu.Lock() + defer c.mu.Unlock() + out := map[string]any{"kind": Kind, "can_send": s.Ready, "delivered_since_start": c.delivered, + "failed_since_start": c.failed, "edits_notify_nobody": true} + if s.Why != "" { + out["why_not"] = s.Why + } + if c.me != "" { + out["bot"] = "@" + c.me + } + if !c.lastPoll.IsZero() { + out["updates_last_arrived"] = c.lastPoll.UTC().Format(time.RFC3339) + } + if !c.lastSent.IsZero() { + out["last_delivered"] = c.lastSent.UTC().Format(time.RFC3339) + } + if c.lastErr != "" { + out["last_error"] = c.lastErr + } + out["rules"] = []string{ + "it shows what the router sends and says what was chosen; whether the sender is the operator is the router's to judge, from the account linked there (novox/hq ADR 0259)", + "a button carries only the router's one-time ticket", + "only a private chat with the account itself counts; a tap is acknowledged at once", + "/start asks to link this account; the code shown on the desk is checked by request and reply, never in an event, and deleted from the chat", + "updates are long-polled: outbound only, no route into the mesh", + "up to 4096 characters arrive whole; longer is cut and the cut said", + "warnings and clearings arrive without sound; urgent messages and questions ring", + "a rate limit is waited out; a refusal is said to the router as permanent; an edit to the same text is a success", + "it runs as its own account, in a runtime of its own on a bus account of its own, never the machine's runtime; that account alone reads the bot token", + } + return out +} diff --git a/modules/telegram/cmd/telegram/channel_test.go b/modules/telegram/cmd/telegram/channel_test.go new file mode 100644 index 00000000..5d6437cc --- /dev/null +++ b/modules/telegram/cmd/telegram/channel_test.go @@ -0,0 +1,432 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +const goodToken = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g" + +// fakeAPI is the Bot API, answering each method as the test says and keeping what it was asked. +type fakeAPI struct { + mu sync.Mutex + calls []call + answer map[string]func(body map[string]any) (int, string) + next int64 +} + +type call struct { + Method string + Body map[string]any +} + +func (f *fakeAPI) of(method string) []call { + f.mu.Lock() + defer f.mu.Unlock() + var out []call + for _, c := range f.calls { + if c.Method == method { + out = append(out, c) + } + } + return out +} + +func newAPI(t *testing.T) *fakeAPI { + t.Helper() + f := &fakeAPI{answer: map[string]func(map[string]any) (int, string){}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + method := parts[len(parts)-1] + if parts[1] != "bot"+goodToken { + w.WriteHeader(401) + _, _ = w.Write([]byte(`{"ok":false,"error_code":401,"description":"Unauthorized"}`)) + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.calls = append(f.calls, call{method, body}) + answer := f.answer[method] + f.next++ + id := f.next + f.mu.Unlock() + if answer != nil { + code, out := answer(body) + w.WriteHeader(code) + _, _ = w.Write([]byte(out)) + return + } + switch method { + case "sendMessage": + _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":` + jsonInt(id) + `,"chat":{"id":42,"type":"private"}}}`)) + case "getMe": + _, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"mesh_bot"}}`)) + default: + _, _ = w.Write([]byte(`{"ok":true,"result":true}`)) + } + })) + t.Cleanup(srv.Close) + was := API + API = srv.URL + t.Cleanup(func() { API = was }) + return f +} + +func jsonInt(n int64) string { b, _ := json.Marshal(n); return string(b) } + +type memStore struct { + offset int64 + placed map[string]Placed + shown map[[2]int64]string +} + +func (m *memStore) Shown(chat, id int64) (string, error) { return m.shown[[2]int64{chat, id}], nil } +func (m *memStore) Show(chat, id int64, digest string) error { + if m.shown == nil { + m.shown = map[[2]int64]string{} + } + m.shown[[2]int64{chat, id}] = digest + return nil +} + +func (m *memStore) Offset() (int64, error) { return m.offset, nil } +func (m *memStore) SetOffset(n int64) error { m.offset = n; return nil } +func (m *memStore) Place(h string, p Placed) error { + if m.placed == nil { + m.placed = map[string]Placed{} + } + m.placed[h] = p + return nil +} +func (m *memStore) Placed(h string) (*Placed, error) { + p, ok := m.placed[h] + if !ok { + return nil, nil + } + return &p, nil +} + +type said struct { + mu sync.Mutex + by map[string][]json.RawMessage + proved []json.RawMessage +} + +func (s *said) of(subject string) []json.RawMessage { + s.mu.Lock() + defer s.mu.Unlock() + return s.by[subject] +} + +func newChannel(t *testing.T, token string) (*Channel, *fakeAPI, *said, *memStore) { + t.Helper() + f := newAPI(t) + file := filepath.Join(t.TempDir(), "telegram-token") + if token != "" { + if err := os.WriteFile(file, []byte(token+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + s := &said{by: map[string][]json.RawMessage{}} + st := &memStore{} + now := time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC) + c := &Channel{Bot: &Bot{TokenFile: file}, Store: st, Now: func() time.Time { return now }, Logf: t.Logf, + Sleep: func(time.Duration) {}, + Publish: func(subject string, body any, id string) error { + raw, _ := json.Marshal(body) + s.mu.Lock() + s.by[subject] = append(s.by[subject], raw) + s.mu.Unlock() + return nil + }, + Prove: func(subject string, body any) (json.RawMessage, error) { + raw, _ := json.Marshal(body) + s.mu.Lock() + s.proved = append(s.proved, raw) + s.mu.Unlock() + if strings.Contains(string(raw), `"code":"123456"`) { + return json.RawMessage(`{"accepted":true,"words":"Linked."}`), nil + } + return json.RawMessage(`{"accepted":false,"words":"That code is not right."}`), nil + }} + c.init() + return c, f, s, st +} + +func work(verb string, m asks.Message) (string, json.RawMessage) { + raw, _ := json.Marshal(m) + return asks.ChannelSubject(verb, Kind), raw +} + +func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T) { + c, f, _, st := newChannel(t, goodToken) + subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Delivery of hq held", Body: "Needs you: release it.", + To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "0123456789abcdef0123456789abcdef"}}}) + if err := c.Take(subject, body); err != nil { + t.Fatal(err) + } + sent := f.of("sendMessage") + if len(sent) != 1 { + t.Fatalf("sent %d", len(sent)) + } + b := sent[0].Body + kb, _ := json.Marshal(b["reply_markup"]) + if b["chat_id"] != float64(42) || b["text"] != "Delivery of hq held\nNeeds you: release it." || b["disable_notification"] != false || + string(kb) != `{"inline_keyboard":[[{"callback_data":"0123456789abcdef0123456789abcdef","text":"Release"}]]}` { + t.Errorf("sent %v %s", b, kb) + } + if _, deprecated := b["disable_web_page_preview"]; deprecated { + t.Error("the deprecated preview flag is still sent (D7)") + } + if st.placed["m1"].ID == 0 { + t.Error("where it is was not kept") + } + subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "Resolved: hq works again", To: "42"}) + _ = c.Take(subject, body) + if f.of("sendMessage")[1].Body["disable_notification"] != true { + t.Error("a clearing rang (D4)") + } + subject, body = work(asks.Send, asks.Message{Handle: "m3", Title: "Urgent: ace silent", Urgent: true, To: "42"}) + _ = c.Take(subject, body) + if f.of("sendMessage")[2].Body["disable_notification"] != false { + t.Error("an urgent message was silent (D4)") + } +} + +func TestALongMessageIsCutAndTheCutSaid(t *testing.T) { + c, f, _, _ := newChannel(t, goodToken) + subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "t", Body: strings.Repeat("x", 5000), To: "42"}) + if err := c.Take(subject, body); err != nil { + t.Fatal(err) + } + got := f.of("sendMessage")[0].Body["text"].(string) + if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) { + t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:]) + } +} + +func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) { + c, f, s, st := newChannel(t, goodToken) + st.Place("m1", Placed{Chat: 42, ID: 7}) + f.answer["editMessageText"] = func(map[string]any) (int, string) { + return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message is not modified"}` + } + subject, body := work(asks.Edit, asks.Message{Handle: "m1", Title: "same", To: "42"}) + if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 0 || len(s.of(asks.IntakeSubject(asks.FailedWhat, Kind))) != 0 { + t.Fatalf("an identical edit was not a success (D6): %v", err) + } + f.answer["editMessageText"] = func(map[string]any) (int, string) { + return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message to edit not found"}` + } + _ = c.Take(subject, body) + if len(f.of("sendMessage")) != 1 { + t.Error("an edit of a message gone was not sent again") + } + if kb, _ := json.Marshal(f.of("editMessageText")[0].Body["reply_markup"]); string(kb) != `{"inline_keyboard":[]}` { + t.Errorf("an edit without buttons left them: %s", kb) + } +} + +func TestARefusalIsPermanentARateLimitIsWaitedOutAndNeitherCarriesTheToken(t *testing.T) { + c, f, s, _ := newChannel(t, goodToken) + tries := 0 + var slept []time.Duration + c.Sleep = func(d time.Duration) { slept = append(slept, d) } + f.answer["sendMessage"] = func(map[string]any) (int, string) { + tries++ + if tries == 1 { + return 429, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":7}}` + } + return 403, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user ` + goodToken + `"}` + } + subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "x", To: "42"}) + if err := c.Take(subject, body); err != nil { + t.Fatal(err) + } + if len(slept) != 1 || slept[0] != 7*time.Second || tries != 2 { + t.Errorf("the rate limit was not waited out as told (D5): slept %v, tries %d", slept, tries) + } + failed := s.of(asks.IntakeSubject(asks.FailedWhat, Kind)) + if len(failed) != 1 || !strings.Contains(string(failed[0]), `"permanent":true`) || strings.Contains(string(failed[0]), goodToken) { + t.Fatalf("the refusal: %s (D6)", failed) + } + // No linked account and nothing to reply to: said as failed for good, never retried. + subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "x"}) + _ = c.Take(subject, body) + if f := s.of(asks.IntakeSubject(asks.FailedWhat, Kind)); len(f) != 2 || !strings.Contains(string(f[1]), "no account is linked") { + t.Errorf("%s", f) + } +} + +func TestATapIsAcknowledgedAtOnceAndSaidWithItsAuthenticatedSender(t *testing.T) { + c, f, s, _ := newChannel(t, goodToken) + shown := showQuestion(t, c, f) + c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"}, + Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Release it?\nThe delivery is held."}}}) + if len(f.of("answerCallbackQuery")) != 1 { + t.Error("the tap was not acknowledged") + } + got := s.of(asks.IntakeSubject(asks.Chosen, Kind)) + var ch asks.Choice + if len(got) != 1 || json.Unmarshal(got[0], &ch) != nil || ch.Ticket != "tkt" || + !reflect.DeepEqual(ch.Sender, asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}) { + t.Fatalf("said %s", got) + } + // In a group, or from another bot, nothing is said: it is nobody's own chat with the bot. + c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42}, + Message: &Msg{Chat: Chat{ID: -100, Type: "group"}}}}) + c.Handle(Update{UpdateID: 3, Callback: &Callback{ID: "cb3", Data: "tkt", From: User{ID: 9, IsBot: true}, + Message: &Msg{Chat: Chat{ID: 9, Type: "private"}}}}) + if len(s.of(asks.IntakeSubject(asks.Chosen, Kind))) != 1 { + t.Error("a tap outside the account's own chat was said") + } +} + +func TestStartAsksALinkAndACodeIsAProofDeletedFromTheChat(t *testing.T) { + c, f, s, _ := newChannel(t, goodToken) + jo := &User{ID: 42, FirstName: "Jo", Username: "jo"} + c.Handle(Update{UpdateID: 10, Message: &Msg{MessageID: 5, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "/start"}}) + var l asks.LinkAsked + got := s.of(asks.IntakeSubject(asks.Link, Kind)) + if len(got) != 1 || json.Unmarshal(got[0], &l) != nil || l.Sender.Identity != "42" || l.Sender.Display != "Jo (@jo)" { + t.Fatalf("link %s", got) + } + // The router's answer to the link goes to the chat it came from, though nobody is linked yet. + subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "Send the code shown on your desk", Reply: l.ID}) + if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 1 || f.of("sendMessage")[0].Body["chat_id"] != float64(42) { + t.Fatalf("the reply did not reach the chat: %v %v", err, f.of("sendMessage")) + } + c.Handle(Update{UpdateID: 11, Message: &Msg{MessageID: 6, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "123 456"}}) + if len(s.proved) != 1 || !strings.Contains(string(s.proved[0]), `"code":"123456"`) { + t.Fatalf("the code was not asked as a proof: %s", s.proved) + } + for subject, events := range s.by { + for _, e := range events { + if strings.Contains(string(e), "123456") { + t.Errorf("the code was said on %s", subject) + } + } + } + if d := f.of("deleteMessage"); len(d) != 1 || d[0].Body["message_id"] != float64(6) { + t.Error("the code was not deleted from the chat") + } + if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != "Linked." { + t.Errorf("the router's answer was not said: %v", last[len(last)-1].Body) + } + c.Handle(Update{UpdateID: 12, Message: &Msg{MessageID: 7, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "release it"}}) + if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != FixedLine { + t.Error("other words were not answered with the fixed line") + } + if c.Store.(*memStore).offset != 0 { + t.Error("the offset moved without a poll") + } +} + +func TestStandingSaysWhatIsMissingAndNeverTheToken(t *testing.T) { + for token, want := range map[string]string{ + "": "no bot token", + "a-random-value-x": "not a bot token", + "987654321:" + strings.Repeat("Z", 35): "does not work", + } { + c, _, _, _ := newChannel(t, token) + s := c.Standing() + if s.Ready || !strings.Contains(s.Why, want) || (token != "" && strings.Contains(s.Why, token)) { + t.Errorf("%q: %+v", token, s) + } + } + c, _, said, _ := newChannel(t, goodToken) + if s := c.Standing(); !s.Ready || !s.EditsSilently { + t.Errorf("%+v", s) + } + c.SayStanding() + c.SayStanding() + if n := len(said.of(asks.IntakeSubject(asks.StandingWhat, Kind))); n != 1 { + t.Errorf("standing said %d times without a change", n) + } +} + +func TestPollTakesTheUpdatesAndKeepsTheOffsetAndSaysAConflict(t *testing.T) { + c, f, s, st := newChannel(t, goodToken) + f.answer["getUpdates"] = func(map[string]any) (int, string) { + return 200, `{"ok":true,"result":[{"update_id":41,"message":{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"text":"/start"}}]}` + } + if err := c.Poll(time.Second); err != nil || st.offset != 42 || len(s.of(asks.IntakeSubject(asks.Link, Kind))) != 1 { + t.Fatalf("%v offset %d", err, st.offset) + } + f.answer["getUpdates"] = func(map[string]any) (int, string) { + return 409, `{"ok":false,"error_code":409,"description":"Conflict: terminated by other getUpdates request"}` + } + if err := c.Poll(time.Second); err == nil || !strings.Contains(err.Error(), "another program takes this bot's updates") { + t.Errorf("%v", err) + } + if s := c.Standing(); s.Ready { + t.Error("ready while the updates do not arrive") + } +} + +// showQuestion shows one question with a button to the linked account, and answers its message's id. +func showQuestion(t *testing.T, c *Channel, f *fakeAPI) int64 { + t.Helper() + subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Release it?", Body: "The delivery is held.", + To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "tkt"}}}) + if err := c.Take(subject, body); err != nil { + t.Fatal(err) + } + sent := f.of("sendMessage") + if len(sent) == 0 { + t.Fatal("the question was not sent") + } + f.mu.Lock() + id := f.next + f.mu.Unlock() + return id +} + +// A tap is said only on a message showing exactly what this channel put there (the review of 2026-10-09): a +// message whose words were changed by another holder of the token — "Release" under other words — or one +// this channel never showed, is refused on the phone and never reaches the router. +func TestATapOnAMessageShowingOtherWordsIsRefused(t *testing.T) { + c, f, s, st := newChannel(t, goodToken) + shown := showQuestion(t, c, f) + tap := func(id string, msg int64, words string) { + c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: id, Data: "tkt", From: User{ID: 42, FirstName: "Jo"}, + Message: &Msg{MessageID: msg, Chat: Chat{ID: 42, Type: "private"}, Text: words}}}) + } + tap("cb-other", shown, "Silence it for a week?\nNothing changes.") + tap("cb-unknown", shown+100, "Release it?\nThe delivery is held.") + if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 0 { + t.Fatalf("a tap on other words was said: %s", got) + } + answers := f.of("answerCallbackQuery") + if len(answers) != 2 || !strings.Contains(answers[0].Body["text"].(string), "not") { + t.Errorf("the phone was not told: %v", answers) + } + // The same words, as Telegram gives them back (its line endings, its outer spaces), are taken. + tap("cb-same", shown, " Release it?\r\nThe delivery is held. ") + if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + t.Fatalf("the tap on the words shown was not said: %s", got) + } + // An edit moves what was shown: the old words are then refused. + subject, body := work(asks.Edit, asks.Message{Handle: "q1", Title: "Released", Body: "by you", To: "42"}) + if err := c.Take(subject, body); err != nil { + t.Fatal(err) + } + tap("cb-old", shown, "Release it?\nThe delivery is held.") + if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 { + t.Errorf("a tap on words since replaced was said") + } + if d, _ := st.Shown(42, shown); d == "" { + t.Error("what an edit showed was not kept") + } +} diff --git a/modules/telegram/cmd/telegram/main.go b/modules/telegram/cmd/telegram/main.go new file mode 100644 index 00000000..2883072d --- /dev/null +++ b/modules/telegram/cmd/telegram/main.go @@ -0,0 +1,133 @@ +// telegram: a channel of the operator's conversation, kind `telegram` of the `channel` and `intake` benches +// (novox/hq ADR 0234, ADR 0259). A Go bundle the node's runtime launches as this module's own account, never +// root and never the operator's: the bot token and this channel's state are that account's alone. stdout is +// the MCP channel; what this module says, it says on stderr. +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// Worker is the work queue of this kind, as the controller names it. +const Worker = "SEAT_CHANNEL_TELEGRAM_worker" + +// busStore keeps the update offset and where each message is in this module's state on the bus. +type busStore struct{} + +func (busStore) Offset() (int64, error) { + e, err := stdio.State("offset").Get("updates") + if err != nil || e == nil { + return 0, err + } + var o struct { + Next int64 `json:"next"` + } + return o.Next, json.Unmarshal(e.Value, &o) +} +func (busStore) SetOffset(next int64) error { + _, err := stdio.State("offset").Put("updates", map[string]int64{"next": next}) + return err +} +func (busStore) Placed(handle string) (*Placed, error) { + e, err := stdio.State("messages").Get(handle) + if err != nil || e == nil { + return nil, err + } + var p Placed + return &p, json.Unmarshal(e.Value, &p) +} +func (busStore) Place(handle string, p Placed) error { + _, err := stdio.State("messages").Put(handle, p) + return err +} + +// shownKey is where what a message shows is kept, beside the router's handles in the same state. +func shownKey(chat, id int64) string { return fmt.Sprintf("shown-words.%d.%d", chat, id) } + +func (busStore) Shown(chat, id int64) (string, error) { + e, err := stdio.State("messages").Get(shownKey(chat, id)) + if err != nil || e == nil { + return "", err + } + var w struct { + Digest string `json:"digest"` + } + return w.Digest, json.Unmarshal(e.Value, &w) +} +func (busStore) Show(chat, id int64, digest string) error { + _, err := stdio.State("messages").Put(shownKey(chat, id), map[string]string{"digest": digest}) + return err +} + +func main() { + c := &Channel{ + Bot: &Bot{TokenFile: os.Getenv("MESH_TELEGRAM_TOKEN_FILE"), Client: &http.Client{Timeout: PollWait + 15*time.Second}}, + Store: busStore{}, + Publish: func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) }, + Prove: stdio.SeatProve, + Logf: logf, + } + c.init() + go run(c) + tools := []stdio.Tool{{Name: "telegram_status", + Description: "Whether this channel can reach the operator on Telegram, and why not: whether the bot token works " + + "(never the token), the bot's name, when updates last arrived, the last delivery and the last error, how " + + "many it delivered and failed since it started, and the rules it keeps. Linking an account as the " + + "operator is the router's (messenger_identities).", + Run: func(map[string]any) (any, error) { return c.Status(), nil }}} + if err := stdio.Serve("", tools); err != nil { + logf("%v", err) + os.Exit(1) + } +} + +// run takes this kind's work, long-polls the updates and says its standing — each retried, each failure said. +func run(c *Channel) { + time.Sleep(500 * time.Millisecond) // Serve first: the bus is reached through it + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.SeatTake(Worker, func(w stdio.Work) error { return c.Take(w.Subject, w.Body) }) + if err == nil { + logf("[telegram] taking the router's messages for this kind") + return + } + logf("[telegram] not taking messages yet (%v); asking again in %s", err, wait) + time.Sleep(wait) + } + }() + go func() { + for { + c.SayStanding() + time.Sleep(time.Minute) + } + }() + said := "" + for { + if _, err := c.Bot.token(); err != nil { + if err.Error() != said { + logf("[telegram] %v", err) + said = err.Error() + } + time.Sleep(time.Minute) + continue + } + if err := c.Poll(PollWait); err != nil { + if err.Error() != said { + logf("[telegram] the updates do not arrive: %v", err) + said = err.Error() + } + c.SayStanding() + time.Sleep(10 * time.Second) + continue + } + said = "" + } +} diff --git a/modules/telegram/cmd/telegram/manifest_test.go b/modules/telegram/cmd/telegram/manifest_test.go new file mode 100644 index 00000000..4baba908 --- /dev/null +++ b/modules/telegram/cmd/telegram/manifest_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The manifest keeps the bot token and this channel's state from every agent (novox/hq ADR 0259 §8): the +// bundle runs as the module's own account, which owns its state directory and its secret; it claims one kind +// on both benches, with what it promises; and it names nothing of one installation. +func TestItRunsAsItsOwnAccountWhichAloneHoldsTheToken(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Claims []struct { + Name, Scope, Kind string + Capabilities []string + } + Own map[string]string `json:"own-secrets"` + Owner string `json:"secrets-owner"` + RunsAs string `json:"runs-as"` + Resources []map[string]any + Build struct { + Artifacts []struct { + Env map[string]string + } + } + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if len(m.Claims) != 2 || m.Claims[0].Name != "channel" || m.Claims[0].Kind != Kind || m.Claims[1].Name != "intake" || + m.Claims[1].Kind != Kind { + t.Fatalf("claims %+v", m.Claims) + } + promises := strings.Join(m.Claims[0].Capabilities, " ") + for _, c := range []string{"reaches-away", "choice", "verified-sender", "edit", "max-length:4096"} { + if !strings.Contains(promises, c) { + t.Errorf("it does not promise %s", c) + } + } + env := m.Build.Artifacts[0].Env + if m.RunsAs != "telegram" || env["MESH_TELEGRAM_TOKEN_FILE"] != m.Own["telegram-token"] || m.Owner != "telegram" || + m.Own["broker"] == "" { + t.Fatalf("runs as %q, env %v, secrets %v, owner %q", m.RunsAs, env, m.Own, m.Owner) + } + var account, state bool + for _, r := range m.Resources { + switch r["type"] { + case "user": + account = r["name"] == "telegram" && r["shell"] == "/usr/bin/nologin" + case "directory": + state = r["id"] == "state" && r["owner"] == "telegram" && r["mode"] == "0700" + } + } + if !account || !state { + t.Errorf("the account %v, its state %v", account, state) + } + for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox", "api.telegram"} { + if strings.Contains(strings.ToLower(string(raw)), never) { + t.Errorf("module.json names %q", never) + } + } +} diff --git a/modules/telegram/go.mod b/modules/telegram/go.mod new file mode 100644 index 00000000..7b6aa768 --- /dev/null +++ b/modules/telegram/go.mod @@ -0,0 +1,5 @@ +module telegram + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.10 diff --git a/modules/telegram/go.sum b/modules/telegram/go.sum new file mode 100644 index 00000000..8446865c --- /dev/null +++ b/modules/telegram/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o= +git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/telegram/module.json b/modules/telegram/module.json new file mode 100644 index 00000000..5534476a --- /dev/null +++ b/modules/telegram/module.json @@ -0,0 +1,78 @@ +{ + "module": "telegram", + "version": "1", + "slug": "tgrm", + "runs-as": "telegram", + "claims": [ + { + "name": "channel", + "scope": "mesh", + "kind": "telegram", + "capabilities": [ + "deliver", + "reaches-away", + "silent", + "edit", + "max-length:4096", + "choice", + "verified-sender", + "exact-render", + "code-factor" + ] + }, + { + "name": "intake", + "scope": "mesh", + "kind": "telegram" + } + ], + "state": [ + "offset", + { + "name": "messages", + "ttl-seconds": 2592000 + } + ], + "own-secrets": { + "broker": "${dir:state}/broker", + "telegram-token": "${dir:state}/telegram-token" + }, + "secrets-owner": "telegram", + "tools": [ + "telegram_status" + ], + "resources": [ + { + "id": "account", + "type": "user", + "name": "telegram", + "shell": "/usr/bin/nologin", + "home": "/var/lib/telegram" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "telegram", + "place": "." + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/telegram", + "binary": "telegram", + "loads": [ + "telegram" + ], + "env": { + "MESH_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token" + } + } + ] + } +}