Make the messenger the router: it holds every ask, judges each answer and issues the warrant its asker acts on, and keeps no channel's client (hq ADR 0259)

This commit is contained in:
jochen
2026-10-09 10:16:17 +02:00
committed by jschoubben
parent 7339ce8556
commit 0cb000f358
20 changed files with 2453 additions and 1264 deletions
+109 -244
View File
@@ -1,41 +1,31 @@
package main
// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253, as
// limited by ADR 0258).
// What the operator needs to do, and answering from a message (novox/hq ADR 0253, ADR 0258, ADR 0259).
//
// - **Every explanation opens with a verdict**, written by the controller: "Nothing for you to do." or
// "Needs you:" and one thing the operator can do themselves.
// - **A warning that needs nothing is not sent.** It is kept open here and in the controller's
// conditions, the history says it was kept quiet, and it is said when it needs the operator — urgent
// at its bound, or its words changed to "Needs you:" (an event, or a reading of the state) — and once
// after a day in any case (QuietRemindAfter), so nothing stays silent for ever.
// - **A desk click performs only an acknowledgement** (ADR 0234 §8). Any process of the operator's account
// can choose a notification's action (`dunstctl action`), so a click proves nothing about who chose;
// until answers are authorised (to-be 46 phase 5) the only answers offered and performed are Details
// and silencing a condition. Everything else — release, stop, start, restart — is said in words and
// offered as no answer. The allow-list is `answerable`; an action outside it is never offered, and a
// token for one is refused.
// - **A token is an answer's one-time ticket**: made for one message of one condition, bound to the
// machines it was shown on, valid for TokenFor, used once. An answer for a condition cleared or
// silenced, from another machine, used, expired or unknown, is refused — and the refusal is shown where
// the answer came from, so a click is never silently dead. One event is acted on once, whatever its
// redelivery.
// - **A notifier that restarted lost the answers it was waiting for**; when it says it started, the open
// notifications that offer answers are shown there again with fresh tokens (Reoffer).
// at its bound, its words changed to "Needs you:", or an ask about it arrived — and once after a day in
// any case (QuietRemindAfter), so nothing stays silent for ever.
// - **The router performs nothing** (ADR 0259). A message offers the answers of the ask about it, which
// its asker performs on a warrant (asks.go), and Details, which performs nothing and is shown where it
// was chosen. The condition's own actions are never called from here: the controller asks for them.
// - **A ticket is an answer's one-time ticket**: made for one message on one channel kind, good for
// TokenFor, used once; refused for a record cleared or silenced, used, expired, from another kind or
// unknown — and the refusal is shown where the answer came from. One answer is acted on once,
// whatever its redelivery.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Action is one answer a condition offers: a label, and the seat verb called with these arguments when
// it is chosen. Machine is set for a seat every machine holds. An argument "why" given empty is filled
// with the operator's words.
// Action is one answer a condition names: a label, and the seat verb its producer calls when it is chosen.
// Kept as the controller says it; the router offers a condition's answers only through an ask about it.
type Action struct {
Label string `json:"label"`
Verb string `json:"verb"`
@@ -43,45 +33,25 @@ type Action struct {
Arguments map[string]string `json:"arguments,omitempty"`
}
// Grant is one token's answer: the action (or -1, Details), when it was made, and whether it was used.
// Grant is one Details ticket: the kind it was shown on, when it was made, and whether it was used.
type Grant struct {
Index int `json:"index"`
Kind string `json:"kind,omitempty"`
Issued time.Time `json:"issued"`
Used bool `json:"used,omitempty"`
}
const (
// DetailsLabel is the answer every condition's notification offers.
// DetailsLabel is the answer every condition's message offers.
DetailsLabel = "Details"
// MostActions bounds what a notification offers, Details included.
// MostActions bounds what a message offers, Details included.
MostActions = 4
// TokenFor is how long an answer's token is good for.
// TokenFor is how long a Details ticket is good for.
TokenFor = 24 * time.Hour
// KeptEvents is how many answered events are remembered, so a redelivered one is acted on once.
// KeptEvents is how many answers are remembered, so a redelivered one is acted on once.
KeptEvents = 200
)
// answerable is the allow-list of answers a desk click performs (ADR 0258): silencing a condition, an
// acknowledgement any granted principal may already make. Nothing else, until answers are authorised.
func answerable(a Action) bool {
if a.Verb != "mesh-controller.conditions" || a.Machine != "" || a.Arguments["silence"] == "" {
return false
}
for k := range a.Arguments {
switch k {
case "silence", "for", "why", "cause":
default:
return false
}
}
return true
}
// answerableFor is answerable for this record: a silence of its own condition, and of no other.
func answerableFor(r *Record, a Action) bool {
return answerable(a) && a.Arguments["silence"] == r.Key
}
func sameActions(a, b []Action) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
@@ -89,82 +59,79 @@ func sameActions(a, b []Action) bool {
}
// needsNothing is a record the operator need not hear of now: words of its own that open "Nothing for
// you to do.", and only a warning. A record without words (an older controller, a notice) is said.
// you to do.", only a warning, and nothing asked about it. A record without words is said.
func needsNothing(r *Record) bool {
return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition"
return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition" && r.Ask == ""
}
// choices are the answers a record's next message offers, each with a fresh token; the tokens of its
// earlier messages end with them.
func (h *Holder) choices(r *Record) []Choice {
if r.Origin != "condition" {
return nil
// choices are the answers a record's next message offers on one kind: the open ask's options offered
// there, and Details for a condition, with a fresh ticket replacing that kind's earlier one.
func (h *Holder) choices(r *Record, kind string) []Choice {
var out []Choice
if rec := h.openAsk(r); rec != nil {
out = h.offers(rec, kind)
}
if r.Origin != "condition" || len(out) >= MostActions {
return out
}
now := h.Now()
h.mu.Lock()
defer h.mu.Unlock()
r.Tokens = map[string]Grant{}
var out []Choice
for i, a := range r.Actions {
if !answerableFor(r, a) || len(out) == MostActions-1 {
continue
}
t := token()
r.Tokens[t] = Grant{Index: i, Issued: now}
out = append(out, Choice{Token: t, Label: a.Label})
if r.Tokens == nil {
r.Tokens = map[string]Grant{}
}
t := token()
r.Tokens[t] = Grant{Index: -1, Issued: now}
for t, g := range r.Tokens {
if g.Kind == kind {
delete(r.Tokens, t)
}
}
t := ticket()
r.Tokens[t] = Grant{Index: -1, Kind: kind, Issued: now}
return append(out, Choice{Token: t, Label: DetailsLabel})
}
func token() string {
var b [12]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// shownOn are the machines a record's desktop message was shown on.
func shownOn(r *Record) []string {
var out []string
for _, part := range strings.Split(r.Sent["desktop"], ",") {
if m, _, ok := strings.Cut(part, "="); ok {
out = append(out, m)
}
}
return out
}
// Chosen takes an answer a channel handed back: the token of a choice, on a machine, in an event. It
// performs an allowed answer, shows what came of it — or why it was refused — where it was chosen, and
// answers that in words. The same event is acted on once.
func (h *Holder) Chosen(channel, machine, tok, event string) (string, error) {
// Chosen takes an answer a channel of a kind said it was given: an ask's option is judged and, when it
// stands, closes the ask with a warrant for its asker (asks.go); Details are shown where they were asked.
// Anything else is refused, and said there. The same answer is acted on once.
func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) {
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
if event != "" {
if outcome, done := h.answered[event]; done {
if c.ID != "" {
if outcome, done := h.answered[c.ID]; done {
h.mu.Unlock()
return outcome, nil
}
}
key := ""
if h.tickets != nil {
key = h.tickets[c.Ticket]
}
now := h.Now()
var r *Record
var g Grant
for _, rec := range h.open {
if gr, ok := rec.Tokens[tok]; ok {
if gr, ok := rec.Tokens[c.Ticket]; ok {
r, g = rec, gr
}
}
h.mu.Unlock()
if !c.At.IsZero() && now.Sub(c.At) > FreshFor {
// Replayed from the stream long after it was given: history, never an answer.
h.Logf("[messenger] an answer on %s from %s is %s old and was not acted on", kind, c.At.Format(time.RFC3339), roughly(now.Sub(c.At)))
h.remember(c.ID, "too old")
return "too old", errorf("it was given %s ago", roughly(now.Sub(c.At)))
}
if key != "" {
out, err := h.judge(kind, c, key)
h.remember(c.ID, out)
return out, err
}
refuse := func(why string) (string, error) {
h.Logf("[messenger] an answer on %s from %s was refused: %s", channel, machine, why)
h.remember(event, "refused: "+why)
h.record(Sent{At: now, Channel: channel, Key: keyOf(r), What: "answer", Outcome: "refused: " + why})
_, _ = h.showOn(channel, machine, Message{Title: "That answer was not taken",
Body: capital(why) + ". Nothing was done.", Quiet: true})
return "refused", errorf("%s", why)
out, err := h.refuseChoice(kind, c, why)
h.remember(c.ID, out)
return out, err
}
switch {
case r == nil:
@@ -177,119 +144,28 @@ func (h *Holder) Chosen(channel, machine, tok, event string) (string, error) {
return refuse("it was already chosen once")
case now.Sub(g.Issued) > TokenFor:
return refuse("it is more than a day old")
case channel == "desktop" && !contains(shownOn(r), machine):
return refuse("it was not shown on " + machine)
case g.Index >= len(r.Actions) || (g.Index >= 0 && !answerableFor(r, r.Actions[g.Index])):
return refuse("it is not an answer a notification may give")
case g.Kind != "" && g.Kind != kind:
return refuse("it was not offered here")
}
h.mu.Lock()
g.Used = true
r.Tokens[tok] = g
r.Tokens[c.Ticket] = g
h.mu.Unlock()
headline, _ := wordsOf(r)
if g.Index == -1 {
// Each line held to the content rule on its own: a line that carries what may not leave is said
// withheld, and the rest is still shown.
var lines []string
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
if refusal, ok := Check(l); !ok {
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
}
lines = append(lines, l)
// Each line held to the content rule on its own: a line that carries what may not leave is said
// withheld, and the rest is still shown.
var lines []string
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
if refusal, ok := Check(l); !ok {
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
}
_, err := h.showOn(channel, machine, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")})
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
h.remember(event, "shown")
h.persist(r)
return "shown", err
lines = append(lines, l)
}
a := r.Actions[g.Index]
args := map[string]any{}
for k, v := range a.Arguments {
args[k] = v
}
if v, ok := a.Arguments["why"]; ok && v == "" {
args["why"] = fmt.Sprintf("chosen on the %s notification %q on %s (a desk click: whoever was at the "+
"operator's session)", channel, headline, machine)
}
var err error
if h.Ask == nil {
err = errorf("this holder cannot call the mesh")
} else {
var raw json.RawMessage
raw, err = h.Ask("seat:"+a.Verb, args)
if err == nil {
err = refusalIn(raw)
}
}
outcome := "done"
title := a.Label + ": done. " + headline
body := "Chosen at " + now.In(h.zone()).Format("15:04") + "."
if err != nil {
outcome = "failed: " + err.Error()
title = "Could not " + strings.ToLower(a.Label) + ": " + headline
body = "The mesh refused it: " + firstLineOf(err.Error())
if _, ok := Check(body); !ok {
body = "The mesh refused it; the reason carries what may not be shown here."
}
}
h.mu.Lock()
r.Answered = a.Label + ": " + outcome
sent := r.Sent[channel]
h.mu.Unlock()
err := h.showOn(kind, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n"), Reply: c.ID})
h.record(Sent{At: now, Channel: kind, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
h.remember(c.ID, "shown")
h.persist(r)
m := Message{Title: title, Body: body, Quiet: err == nil}
if ch := h.channelNamed(channel); ch != nil && sent != "" && ch.CanEdit() {
if e := ch.Edit(sent, m); e != nil {
_, _ = h.showOn(channel, machine, m)
}
} else {
_, _ = h.showOn(channel, machine, m)
}
h.remember(event, outcome)
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "answer " + a.Label, Outcome: outcome})
h.Logf("[messenger] %s was chosen for %s on %s (%s): %s", a.Label, r.Key, channel, machine, outcome)
return outcome, err
}
// Reoffer shows again, on a machine whose notifier restarted and so lost the answers it waited for, every
// open message shown there that offers answers — with fresh tokens, in place of the old one.
func (h *Holder) Reoffer(machine string) int {
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
var recs []*Record
for _, r := range h.open {
if r.Cleared.IsZero() && len(r.Tokens) > 0 && contains(shownOn(r), machine) {
recs = append(recs, r)
}
}
h.mu.Unlock()
sort.Slice(recs, func(i, j int) bool { return recs[i].Key < recs[j].Key })
n := 0
for _, r := range recs {
if r.silenced(h.Now()) || h.Desktop == nil {
continue
}
m := h.say(r, "raised")
id := ""
for _, part := range strings.Split(r.Sent["desktop"], ",") {
if strings.HasPrefix(part, machine+"=") {
id = part
}
}
if err := h.Desktop.Edit(id, m); err != nil {
h.Logf("[messenger] could not offer %s again on %s: %v", r.Key, machine, err)
continue
}
h.persist(r)
n++
}
if n > 0 {
h.Logf("[messenger] the notifier on %s started again: %d message(s) with answers shown there again", machine, n)
}
return n
return "shown", err
}
func (h *Holder) remember(event, outcome string) {
@@ -310,13 +186,6 @@ func (h *Holder) remember(event, outcome string) {
h.answered[event] = outcome
}
func keyOf(r *Record) string {
if r == nil {
return "(none)"
}
return r.Key
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
@@ -341,43 +210,39 @@ func firstLineOf(s string) string {
return s
}
// refusalIn reads a tool's answer for a refusal: an error result is an error.
func refusalIn(raw json.RawMessage) error {
var wrapped struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
Error string `json:"error"`
}
if json.Unmarshal(raw, &wrapped) == nil {
if wrapped.IsError && len(wrapped.Content) > 0 {
return errorf("%s", wrapped.Content[0].Text)
}
if wrapped.Error != "" {
return errorf("%s", wrapped.Error)
// Failed takes a channel's word that a message did not arrive: the channel is said to be failing, and the
// record it carried is tried again — unless the failure is permanent, which is said and not repeated.
func (h *Holder) Failed(kind string, f asks.Failed) {
h.work.Lock()
defer h.work.Unlock()
h.noteChannel(kind, errorf("%s", f.Why))
h.mu.Lock()
var r *Record
for _, rec := range h.open {
if rec.Sent[kind] == f.Handle && f.Handle != "" {
r = rec
}
}
return nil
if r != nil && r.Cleared.IsZero() && !f.Permanent {
delete(r.Sent, kind)
if r.Pending == "" {
r.Pending = "raised"
}
}
h.mu.Unlock()
outcome := "failed: " + f.Why
if f.Permanent {
outcome = "failed for good: " + f.Why
}
h.record(Sent{At: h.Now(), Channel: kind, Key: keyOrNone(r), What: "delivery", Outcome: outcome})
if r != nil {
h.persist(r)
}
}
func (h *Holder) channelNamed(name string) Channel {
for _, ch := range h.channels() {
if ch.Name() == name {
return ch
}
func keyOrNone(r *Record) string {
if r == nil {
return "(none)"
}
return nil
}
// showOn shows a message of the holder's own on one channel, on one machine where the channel has several.
func (h *Holder) showOn(channel, machine string, m Message) (string, error) {
ch := h.channelNamed(channel)
if ch == nil {
return "", errorf("no channel %s", channel)
}
if d, ok := ch.(*Desktop); ok && machine != "" {
return d.showOne(machine, m)
}
return ch.Send(m)
return r.Key
}
+535 -241
View File
@@ -2,298 +2,592 @@ package main
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// The review of 2026-10-08 (novox/hq ADR 0258): a desk click performs only an acknowledgement, and an
// answer's token is a one-time ticket bound to its condition and its machine.
// The router of novox/hq ADR 0259: it holds every ask, judges each answer and issues a warrant; it
// performs nothing. These tests hold it to every rule of the record's §4, §5 and §7.
// answering is a holder with the failed-units condition shown on the desktop of g14, and the calls the
// holder makes recorded.
func answering(t *testing.T, actions ...Action) (*Holder, *fakeChannel, *clock, *[]string, Condition) {
t.Helper()
h, _, dt, c, _ := newHolder(t)
h.Telegram = nil
asked := &[]string{}
h.Ask = func(address string, body any) (json.RawMessage, error) {
raw, _ := json.Marshal(body)
*asked = append(*asked, address+" "+string(raw))
return json.RawMessage(`{"ok":true}`), nil
}
e := examples[3]
if len(actions) > 0 {
e.actions = actions
}
k, err := DecodeCondition(EventRaised, e.event(c.now(), false))
if err != nil {
t.Fatal(err)
}
h.Condition(EventRaised, k)
settle(h, c)
if len(dt.sends) != 1 {
t.Fatalf("desktop sends %d", len(dt.sends))
}
return h, dt, c, asked, k
// memAsks is the router's state in memory, with the compare-and-set the bus gives.
type memAsks struct {
asks map[string]AskRecord
revs map[string]uint64
ids map[string]Identity
links map[string]PendingLink
rev uint64
}
func tokenOf(t *testing.T, m Message, label string) string {
func newMemAsks() *memAsks {
return &memAsks{asks: map[string]AskRecord{}, revs: map[string]uint64{}, ids: map[string]Identity{},
links: map[string]PendingLink{}}
}
func clone(r AskRecord) AskRecord {
raw, _ := json.Marshal(r)
var out AskRecord
_ = json.Unmarshal(raw, &out)
return out
}
func (m *memAsks) GetAsk(k string) (*AskRecord, uint64, error) {
r, ok := m.asks[k]
if !ok {
return nil, 0, nil
}
c := clone(r)
return &c, m.revs[k], nil
}
func (m *memAsks) CreateAsk(k string, r AskRecord) (uint64, error) {
if _, ok := m.asks[k]; ok {
return 0, ErrChanged
}
m.rev++
m.asks[k], m.revs[k] = clone(r), m.rev
return m.rev, nil
}
func (m *memAsks) UpdateAsk(k string, r AskRecord, rev uint64) (uint64, error) {
if m.revs[k] != rev {
return 0, ErrChanged
}
m.rev++
m.asks[k], m.revs[k] = clone(r), m.rev
return m.rev, nil
}
func (m *memAsks) AskKeys() ([]string, error) {
var out []string
for k := range m.asks {
out = append(out, k)
}
return out, nil
}
func (m *memAsks) GetIdentity(k string) (*Identity, error) {
id, ok := m.ids[k]
if !ok {
return nil, nil
}
return &id, nil
}
func (m *memAsks) PutIdentity(k string, id Identity) error { m.ids[k] = id; return nil }
func (m *memAsks) DeleteIdentity(k string) error { delete(m.ids, k); return nil }
func (m *memAsks) IdentityKeys() ([]string, error) {
var out []string
for k := range m.ids {
out = append(out, k)
}
return out, nil
}
func (m *memAsks) GetLink(kind string) (*PendingLink, error) {
l, ok := m.links[kind]
if !ok {
return nil, nil
}
return &l, nil
}
func (m *memAsks) PutLink(kind string, l PendingLink) error { m.links[kind] = l; return nil }
func (m *memAsks) DeleteLink(kind string) error { delete(m.links, kind); return nil }
// told is what the router published to askers, by subject.
type told struct{ by map[string][]asks.Warrant }
func (w *told) of(asker string) []asks.Warrant { return w.by[asks.DecidedSubject(asker)] }
// newRouter is a router with two kinds: telegram, which reaches the operator away and verifies its sender,
// and the desk, which does neither — as the controller would issue them from the claims.
func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memAsks, *told) {
t.Helper()
h, tg, dt, c, _ := newHolder(t)
store := newMemAsks()
w := &told{by: map[string][]asks.Warrant{}}
bench := NewBench(c.now)
bench.SetKinds([]KindInfo{
{Kind: "telegram", Module: "telegram", Capabilities: []string{"deliver", "reaches-away", "choice", "edit", "verified-sender"}},
{Kind: "desktop", Module: "desk-channel", Capabilities: []string{"deliver", "choice", "edit"}},
})
bench.Stand("telegram", Standing{Ready: true, At: c.now()})
bench.Stand("desktop", Standing{Ready: true, At: c.now()})
h.Bench, h.Asks = bench, store
h.Zone = func() *time.Location { return time.UTC }
h.Publish = func(subject string, body any, id string) error {
if wt, ok := body.(asks.Warrant); ok {
w.by[subject] = append(w.by[subject], wt)
}
return nil
}
return h, tg, dt, c, store, w
}
// stand keeps the channels' word fresh as the clock moves.
func stand(h *Holder, c *clock) {
h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()})
h.Bench.Stand("desktop", Standing{Ready: true, At: c.now()})
}
func heldAsk(c *clock, id string) asks.Ask {
return asks.Ask{ID: id, Headline: "Delivery of hq held for 36 hours",
Explanation: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours.",
Who: asks.Operator, Expires: c.now().Add(2 * time.Hour), OnExpiry: "the delivery stays held",
Options: []asks.Option{
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: asks.Approve},
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: asks.Approve},
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said of it for a week", Level: asks.Acknowledge},
}}
}
func submit(t *testing.T, h *Holder, asker string, a asks.Ask) {
t.Helper()
raw, _ := json.Marshal(a)
if err := h.TakeAsk("ask", asker, raw); err != nil {
t.Fatal(err)
}
}
func link(h *Holder, store *memAsks, c *clock, identity string, at time.Time) {
store.ids[identityKey("telegram", identity)] = Identity{Kind: "telegram", Identity: identity, Display: "Jo",
Linked: at, ApproveFrom: at.Add(LinkedApproveAfter)}
}
func choiceOf(ticket, identity string, at time.Time) asks.Choice {
return asks.Choice{ID: "c-" + ticket + "-" + identity, Ticket: ticket, At: at,
Sender: asks.Sender{Identity: identity, Display: "Jo", Authenticated: true}}
}
func ticketFor(m Message, label string) string {
for _, a := range m.Actions {
if a.Label == label {
return a.Token
}
}
t.Fatalf("%q is not offered: %+v", label, m.Actions)
return ""
}
// **Only an acknowledgement is offered or performed.** Release, Stop, Start and Restart are refused by the
// allow-list: never offered, and a token for one (forged into the state) is refused without a call.
func TestOnlyAnAcknowledgementIsAnAnswer(t *testing.T) {
release := Action{Label: "Release", Verb: "mesh-delivery.release", Arguments: map[string]string{"id": "x", "why": ""}}
restart := Action{Label: "Restart", Verb: "node-service-manager.restart", Machine: "g14",
Arguments: map[string]string{"unit": "x.service"}}
start := Action{Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": "p", "why": ""}}
sneaky := Action{Label: "Silence", Verb: "mesh-controller.conditions", Arguments: map[string]string{"silence": "k", "stop": "p"}}
silence := Action{Label: "Silence for a week", Verb: "mesh-controller.conditions",
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": "", "cause": "operator-answer"}}
for _, a := range []Action{release, restart, start, sneaky} {
if answerable(a) {
t.Errorf("%s is answerable", a.Label)
func labelsOf(m Message) string {
var out []string
for _, a := range m.Actions {
out = append(out, a.Label)
}
return strings.Join(out, ", ")
}
func TestAnAskIsOfferedOnEachKindAsFarAsItCanCarryIt(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
a := heldAsk(c, "d-1")
a.Urgent = true
submit(t, h, "mesh-delivery", a)
settle(h, c)
if len(tg.sends) != 1 || len(dt.sends) != 1 {
t.Fatalf("telegram %d, desk %d", len(tg.sends), len(dt.sends))
}
if got := labelsOf(tg.sends[0]); got != "Release, Stop, Silence for a week" {
t.Errorf("telegram offers %q", got)
}
// The desk proves nothing about who clicked: acknowledgements only, and the words say where the rest go.
if got := labelsOf(dt.sends[0]); got != "Silence for a week" {
t.Errorf("the desk offers %q", got)
}
if !strings.Contains(dt.sends[0].Body, "Release or Stop: answer on Telegram, or from the mesh MCP server.") {
t.Errorf("the desk does not say where to answer: %q", dt.sends[0].Body)
}
if rec := store.asks["mesh-delivery.d-1"]; rec.State != StateOpen || len(rec.Tickets) != 4 {
t.Errorf("recorded %+v", rec)
}
}
func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) {
h, tg, _, c, _, _ := newRouter(t)
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
c.pass(BurstWindow)
h.Flush()
if len(tg.sends) == 0 {
t.Fatal("nothing was said")
}
m := tg.sends[0]
if got := labelsOf(m); got != "Silence for a week" || !strings.Contains(m.Body, "Release or Stop: from the mesh MCP server.") {
t.Errorf("telegram without a link offers %q and says %q", got, m.Body)
}
}
func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
h, tg, dt, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
release := ticketFor(tg.sends[0], "Release")
out, err := h.Chosen("telegram", choiceOf(release, "42", c.now()))
if err != nil || out != "chosen" {
t.Fatalf("%s %v", out, err)
}
got := w.of("mesh-delivery")
if len(got) != 1 || got[0].Outcome != asks.OutcomeChosen || got[0].Option != "release" || got[0].Channel != "telegram" ||
got[0].Says() != "the operator, via telegram (user id verified), chose Release" || strings.Join(got[0].Proofs, ",") != "P1" {
t.Fatalf("the warrant: %+v", got)
}
if _, err := got[0].For("mesh-delivery", heldAsk(c, "d-1")); err != nil {
t.Errorf("the asker would not act on it: %v", err)
}
if rec := store.asks["mesh-delivery.d-1"]; rec.State != string(asks.OutcomeChosen) || !rec.Told || !rec.Tickets[release].Used {
t.Errorf("the record: %+v", rec)
}
// Every copy says how it ended, its buttons gone.
for _, ch := range []*fakeChannel{tg, dt} {
var edited Message
for _, m := range ch.edits {
edited = m
}
if len(edited.Actions) != 0 || !strings.Contains(edited.Body, "Release: chosen at") {
t.Errorf("%s still offers %q: %q", ch.name, labelsOf(edited), edited.Body)
}
}
if !answerable(silence) {
t.Error("silence is not answerable")
// One warrant: the same answer again, and another answer after it, are refused and said.
if out, _ := h.Chosen("telegram", choiceOf(release, "42", c.now())); out != "chosen" {
t.Errorf("a redelivery of the same answer was acted on again: %s", out)
}
h, dt, _, asked, _ := answering(t, release, restart, start, silence)
m := dt.sends[0]
var labels []string
for _, a := range m.Actions {
labels = append(labels, a.Label)
again := choiceOf(release, "42", c.now())
again.ID = "another"
if out, err := h.Chosen("telegram", again); err == nil || out != "refused" {
t.Errorf("a used ticket was taken: %s", out)
}
if strings.Join(labels, ",") != "Silence for a week,Details" {
t.Fatalf("offered %v", labels)
stop := ticketFor(tg.sends[0], "Stop")
if out, err := h.Chosen("telegram", choiceOf(stop, "42", c.now())); err == nil || !strings.Contains(err.Error(), "already answered") {
t.Errorf("a second answer stood: %s %v", out, err)
}
// A token for Release, put in the state by hand: refused, nothing called, and said on the desk.
h.mu.Lock()
r := h.open["machine.shanks.units"]
r.Tokens["forged"] = Grant{Index: 0, Issued: h.Now()}
h.mu.Unlock()
before := len(dt.sends)
if out, err := h.Chosen("desktop", "g14", "forged", "e1"); err == nil || out != "refused" || len(*asked) != 0 {
t.Fatalf("a release was performed from a click: %q %v %v", out, err, *asked)
if len(w.of("mesh-delivery")) != 1 {
t.Errorf("more than one warrant: %+v", w.of("mesh-delivery"))
}
if len(dt.sends) != before+1 || dt.sends[len(dt.sends)-1].Title != "That answer was not taken" {
t.Errorf("the refusal was not said where the click was: %+v", dt.sends[len(dt.sends)-1])
if len(w.of("mesh-controller")) != 0 {
t.Error("another asker heard the warrant")
}
}
// **At most four answers**, Details included.
func TestANotificationOffersAtMostFourAnswers(t *testing.T) {
var many []Action
for i := 0; i < 6; i++ {
many = append(many, Action{Label: fmt.Sprintf("Silence %d", i), Verb: "mesh-controller.conditions",
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}})
}
_, dt, _, _, _ := answering(t, many...)
if n := len(dt.sends[0].Actions); n != MostActions || dt.sends[0].Actions[n-1].Label != DetailsLabel {
t.Fatalf("offered %+v", dt.sends[0].Actions)
func TestEveryAnswerThatIsNotTheOperatorsIsRefusedAndSaid(t *testing.T) {
for name, tc := range map[string]struct {
kind, identity string
authenticated bool
linkedAgo time.Duration
label string
pass time.Duration
want string
}{
"an account not linked": {"telegram", "666", true, 2 * time.Hour, "Release", 0, "not linked as the operator"},
"a sender the service did not say": {"telegram", "42", false, 2 * time.Hour, "Release", 0, "did not authenticate"},
"a link younger than an hour": {"telegram", "42", true, 59 * time.Minute, "Release", 0, "can approve from"},
"an ask that expired": {"telegram", "42", true, 2 * time.Hour, "Release", 3 * time.Hour, "expired"},
"a ticket shown on another kind": {"desktop", "g14", true, 2 * time.Hour, "Release", 0, "not offered here"},
} {
t.Run(name, func(t *testing.T) {
h, tg, _, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-tc.linkedAgo))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
ticket := ticketFor(tg.sends[0], tc.label)
if ticket == "" {
t.Fatalf("no %s offered: %q", tc.label, labelsOf(tg.sends[0]))
}
c.pass(tc.pass)
ch := choiceOf(ticket, tc.identity, c.now())
ch.Sender.Authenticated = tc.authenticated
before := len(tg.sends)
out, err := h.Chosen(tc.kind, ch)
if err == nil || out != "refused" || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s %v, want refused for %q", out, err, tc.want)
}
if len(w.of("mesh-delivery")) != 0 {
t.Error("a warrant was issued")
}
if tc.kind == "telegram" && (len(tg.sends) != before+1 || tg.sends[len(tg.sends)-1].Title != "That answer was not taken") {
t.Errorf("the refusal was not said on the channel: %+v", tg.sends[before:])
}
})
}
}
// **A token is used once, an event acted on once**: the same token twice is refused the second time; the
// same event delivered twice is acted on once and answers what came of it.
func TestATokenIsUsedOnceAndAnEventActedOnOnce(t *testing.T) {
h, dt, _, asked, _ := answering(t)
tok := tokenOf(t, dt.sends[0], "Silence for a week")
if out, err := h.Chosen("desktop", "g14", tok, "e1"); err != nil || out != "done" || len(*asked) != 1 {
t.Fatalf("%q %v %v", out, err, *asked)
}
if out, err := h.Chosen("desktop", "g14", tok, "e1"); err != nil || out != "done" || len(*asked) != 1 {
t.Fatalf("a redelivered event was acted on again: %q %v %v", out, err, *asked)
}
if out, err := h.Chosen("desktop", "g14", tok, "e2"); err == nil || out != "refused" || len(*asked) != 1 {
t.Fatalf("the same token was taken twice: %q %v %v", out, err, *asked)
}
}
// **A token is bound to its machine and its condition's life**: from another machine, for a condition
// cleared or silenced, or past its day, it is refused.
func TestATokenIsRefusedElsewhereAfterItsConditionAndPastItsDay(t *testing.T) {
h, dt, c, asked, k := answering(t)
tok := tokenOf(t, dt.sends[0], "Silence for a week")
if out, _ := h.Chosen("desktop", "shanks", tok, "e1"); out != "refused" {
t.Errorf("an answer from a machine it was not shown on was taken: %q", out)
}
if out, _ := h.Chosen("desktop", "g14", "0123456789abcdef01234567", "e2"); out != "refused" {
t.Errorf("an unknown token was taken: %q", out)
}
c.pass(TokenFor + time.Minute)
if out, _ := h.Chosen("desktop", "g14", tok, "e3"); out != "refused" {
t.Errorf("a token past its day was taken: %q", out)
}
h, dt, c, asked, k = answering(t)
tok = tokenOf(t, dt.sends[0], "Silence for a week")
k.SilencedTill = c.now().Add(time.Hour)
h.Condition(EventChanged, k)
if out, _ := h.Chosen("desktop", "g14", tok, "e4"); out != "refused" {
t.Errorf("an answer for a silenced condition was taken: %q", out)
}
h, dt, c, asked, k = answering(t)
tok = tokenOf(t, dt.sends[0], "Silence for a week")
h.Condition(EventCleared, Condition{Key: k.Key})
if out, _ := h.Chosen("desktop", "g14", tok, "e5"); out != "refused" || len(*asked) != 0 {
t.Errorf("an answer for a cleared condition was taken: %q %v", out, *asked)
}
}
// **A refused verb is said where it was chosen**: the mesh's refusal edits the notification.
func TestAVerbTheMeshRefusesIsSaid(t *testing.T) {
h, dt, _, _, _ := answering(t)
h.Ask = func(string, any) (json.RawMessage, error) {
return json.RawMessage(`{"isError":true,"content":[{"text":"silence is refused: no such condition"}]}`), nil
}
tok := tokenOf(t, dt.sends[0], "Silence for a week")
out, err := h.Chosen("desktop", "g14", tok, "e1")
if err == nil || !strings.HasPrefix(out, "failed") {
t.Fatalf("%q %v", out, err)
}
if m := dt.edits["g14=1"]; !strings.HasPrefix(m.Title, "Could not silence for a week") ||
!strings.Contains(m.Body, "no such condition") {
t.Errorf("the refusal was not said: %+v", m)
}
}
// **A words-only change to "Needs you" after a quiet time is said**, through the event and through a
// reading of the state; and a quiet warning is said once after a day.
func TestAQuietWarningIsSaidWhenItsWordsNeedTheOperator(t *testing.T) {
quiet := examples[0]
needing := quiet
needing.needs = "check that mesh-delivery runs."
needing.explanation = "Needs you: check that mesh-delivery runs. It has waited."
// Through the event.
h, tg, dt, c, _ := newHolder(t)
k, _ := DecodeCondition(EventRaised, quiet.event(c.now(), false))
h.Condition(EventRaised, k)
runFor(h, c, 2*time.Hour)
if len(dt.sends)+len(tg.sends) != 0 {
t.Fatal("said while it needed nothing")
}
k, _ = DecodeCondition(EventChanged, needing.event(c.now(), false))
h.Condition(EventChanged, k)
func TestALinkApprovesAfterAnHourAndAcknowledgesAtOnce(t *testing.T) {
h, tg, _, c, store, w := newRouter(t)
link(h, store, c, "42", c.now())
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: check that mesh-delivery runs.") {
t.Fatalf("event: desktop %+v", dt.sends)
release, silence := ticketFor(tg.sends[0], "Release"), ticketFor(tg.sends[0], "Silence for a week")
c.pass(59*time.Minute - BurstWindow)
if out, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err == nil || out != "refused" {
t.Fatalf("approved at 59 minutes: %s", out)
}
c.pass(time.Minute)
stand(h, c)
late := choiceOf(release, "42", c.now().Add(time.Second))
late.ID = "at-sixty"
if out, err := h.Chosen("telegram", late); err != nil || out != "chosen" {
t.Fatalf("refused at 60 minutes: %s %v", out, err)
}
// And an acknowledgement is taken at once from a fresh link.
h2, tg2, _, c2, store2, w2 := newRouter(t)
link(h2, store2, c2, "42", c2.now())
submit(t, h2, "mesh-delivery", heldAsk(c2, "d-2"))
settle(h2, c2)
silence = ticketFor(tg2.sends[0], "Silence for a week")
if out, err := h2.Chosen("telegram", choiceOf(silence, "42", c2.now())); err != nil || out != "chosen" {
t.Fatalf("an acknowledgement from a fresh link was refused: %s %v", out, err)
}
if got := w2.of("mesh-delivery"); len(got) != 1 || got[0].Level != asks.Acknowledge {
t.Errorf("%+v", got)
}
_ = w
}
// Through a reading of the state.
h, tg, dt, c, _ = newHolder(t)
k, _ = DecodeCondition(EventRaised, quiet.event(c.now(), false))
h.Condition(EventRaised, k)
runFor(h, c, 2*time.Hour)
k, _ = DecodeCondition("conditions", needing.event(c.now(), false))
k.At = time.Time{}
h.Sync([]Condition{k}, c.now())
func TestTheDeskAcknowledgesAndNeverApproves(t *testing.T) {
h, _, dt, c, _, w := newRouter(t)
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: check that mesh-delivery runs.") {
t.Fatalf("sync: desktop %+v", dt.sends)
silence := ticketFor(dt.sends[0], "Silence for a week")
ch := choiceOf(silence, "g14", c.now())
ch.Sender.Authenticated = false
if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" {
t.Fatalf("%s %v", out, err)
}
got := w.of("mesh-delivery")
if len(got) != 1 || got[0].By.Verified != "a desk click: whoever was at the operator's session on g14" || len(got[0].Proofs) != 0 {
t.Errorf("%+v", got)
}
// An approve ticket forged onto the desk is refused: the kind cannot carry it.
h2, tg2, _, c2, store2, _ := newRouter(t)
link(h2, store2, c2, "42", c2.now().Add(-2*time.Hour))
submit(t, h2, "mesh-delivery", heldAsk(c2, "d-1"))
settle(h2, c2)
rec := store2.asks["mesh-delivery.d-1"]
release := ticketFor(tg2.sends[0], "Release")
tk := rec.Tickets[release]
tk.Kind = "desktop"
rec.Tickets[release] = tk
store2.asks["mesh-delivery.d-1"] = rec
h2.keepAsk("mesh-delivery.d-1", rec, store2.revs["mesh-delivery.d-1"])
if out, err := h2.Chosen("desktop", choiceOf(release, "g14", c2.now())); err == nil || out != "refused" ||
!strings.Contains(err.Error(), "can only acknowledge") {
t.Errorf("the desk approved: %s %v", out, err)
}
}
// After a day, once, even if it still needs nothing.
h, tg, dt, c, _ = newHolder(t)
c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC)
k, _ = DecodeCondition(EventRaised, quiet.event(c.now(), false))
h.Condition(EventRaised, k)
for i := 0; i < 50; i++ {
c.pass(time.Hour)
h.Tick()
settle(h, c)
func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) {
h, _, _, c, store, w := newRouter(t)
bad := heldAsk(c, "d-1")
bad.Options[0].Level = asks.Destroy
submit(t, h, "mesh-delivery", bad)
carrying := heldAsk(c, "d-2")
carrying.Explanation = "Needs you: see /etc/passwd on the machine."
submit(t, h, "mesh-delivery", carrying)
for i := 3; i <= 6; i++ {
a := heldAsk(c, "d-"+string(rune('0'+i)))
a.About = "about-" + string(rune('0'+i))
submit(t, h, "mesh-delivery", a)
}
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Still open after a day: ") ||
!strings.HasPrefix(dt.sends[0].Body, "Nothing for you to do.") {
t.Fatalf("after a day: desktop %+v", dt.sends)
got := w.of("mesh-delivery")
var refused []string
for _, g := range got {
if g.Outcome == asks.OutcomeRefused {
refused = append(refused, g.Ask+": "+g.Words)
}
}
all := strings.Join(refused, "\n")
for _, want := range []string{"d-1: the ask is refused: an ask that destroys lasts at most", "d-2: its words carry",
"d-6: its asker already has 3 open asks"} {
if !strings.Contains(all, want) {
t.Errorf("not refused: %q in\n%s", want, all)
}
}
if _, kept := store.asks["mesh-delivery.d-1"]; kept {
t.Error("a refused ask was recorded")
}
// No channel can carry anything: refused, never queued in silence.
h2, _, _, c2, _, w2 := newRouter(t)
h2.Bench.Stand("telegram", Standing{Ready: false, Why: "no bot token", At: c2.now()})
h2.Bench.Stand("desktop", Standing{Ready: false, Why: "no session", At: c2.now()})
submit(t, h2, "mesh-delivery", heldAsk(c2, "d-1"))
if got := w2.of("mesh-delivery"); len(got) != 1 || !strings.Contains(got[0].Words, "no channel can carry any of its answers now") {
t.Errorf("%+v", got)
}
}
func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
h, tg, _, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
c.pass(2 * time.Hour)
stand(h, c)
h.Tick()
got := w.of("mesh-delivery")
if len(got) != 1 || got[0].Outcome != asks.OutcomeExpired || got[0].Option != "" || !strings.Contains(got[0].Words, "the delivery stays held") {
t.Fatalf("%+v", got)
}
var edited Message
for _, m := range tg.edits {
edited = m
}
if len(edited.Actions) != 0 || !strings.Contains(edited.Body, "No longer answerable") {
t.Errorf("the copy still offers answers: %+v", edited)
}
}
func TestANewerAskAboutTheSameThingReplacesTheOlderAndACancelEndsIt(t *testing.T) {
h, _, _, c, store, w := newRouter(t)
a := heldAsk(c, "d-1")
a.About = "delivery.hq"
submit(t, h, "mesh-delivery", a)
b := heldAsk(c, "d-2")
b.About = "delivery.hq"
submit(t, h, "mesh-delivery", b)
if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) {
t.Errorf("the older ask was not replaced: %s", store.asks["mesh-delivery.d-1"].State)
}
if err := h.TakeAsk("cancel", "mesh-delivery", json.RawMessage(`{"id":"d-2"}`)); err != nil {
t.Fatal(err)
}
got := w.of("mesh-delivery")
if len(got) != 2 || got[0].Outcome != asks.OutcomeReplaced || got[1].Outcome != asks.OutcomeCancelled {
t.Errorf("%+v", got)
}
// Another asker cannot cancel it: its cancel names its own ask, under its own name.
submit(t, h, "mesh-delivery", heldAsk(c, "d-3"))
_ = h.TakeAsk("cancel", "mesh-controller", json.RawMessage(`{"id":"d-3"}`))
if store.asks["mesh-delivery.d-3"].State != StateOpen {
t.Error("another asker's cancel ended it")
}
}
func TestAnAskAboutAConditionIsAnsweredOnItsMessage(t *testing.T) {
h, tg, dt, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
k := cond("delivery.hq.held", Warning, "the delivery of hq is held")
k.Raised = c.now()
h.Condition(EventRaised, k)
settle(h, c)
a := heldAsk(c, "d-1")
a.About = "delivery.hq.held"
submit(t, h, "mesh-controller", a)
settle(h, c)
// Its first message was on the desk alone (a warning, the desk ready); the ask re-shows it with answers.
var shown Message
for _, m := range dt.edits {
shown = m
}
if got := labelsOf(shown); got != "Silence for a week, Details" {
t.Errorf("the desk's copy offers %q: %+v", got, shown)
}
if store.asks["mesh-controller.d-1"].Record != "delivery.hq.held" {
t.Errorf("the ask is shown on %q", store.asks["mesh-controller.d-1"].Record)
}
silence := ticketFor(shown, "Silence for a week")
if out, err := h.Chosen("desktop", choiceOf(silence, "g14", c.now())); err != nil || out != "chosen" {
t.Fatalf("%s %v", out, err)
}
if got := w.of("mesh-controller"); len(got) != 1 || got[0].About != "delivery.hq.held" {
t.Errorf("%+v", got)
}
_ = tg
}
// **A notifier that started again is offered the open answers again**, with fresh tokens: the old ones
// are refused, the new ones taken.
func TestANotifierThatRestartedIsOfferedTheAnswersAgain(t *testing.T) {
h, dt, _, asked, _ := answering(t)
old := tokenOf(t, dt.sends[0], "Silence for a week")
if n := h.Reoffer("g14"); n != 1 {
t.Fatalf("offered again: %d", n)
func TestAnAskerNotToldIsToldAgain(t *testing.T) {
h, tg, _, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
pub := h.Publish
h.Publish = func(string, any, string) error { return errorf("the bus is away") }
if out, _ := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Stop"), "42", c.now())); out != "chosen" {
t.Fatal(out)
}
again := dt.edits["g14=1"]
fresh := tokenOf(t, again, "Silence for a week")
if fresh == old || again.Title != dt.sends[0].Title {
t.Fatalf("not offered again: %+v", again)
if store.asks["mesh-delivery.d-1"].Told {
t.Fatal("told while the bus was away")
}
if out, _ := h.Chosen("desktop", "g14", old, "e1"); out != "refused" {
t.Errorf("the lost answer's token still works: %q", out)
}
if out, _ := h.Chosen("desktop", "g14", fresh, "e2"); out != "done" || len(*asked) != 1 {
t.Errorf("the fresh token: %q %v", out, *asked)
}
if n := h.Reoffer("shanks"); n != 0 {
t.Errorf("offered on a machine it was never shown on: %d", n)
h.Publish = pub
c.pass(time.Minute)
stand(h, c)
h.Tick()
if got := w.of("mesh-delivery"); len(got) != 1 || got[0].Option != "stop" || !store.asks["mesh-delivery.d-1"].Told {
t.Errorf("not told again: %+v", got)
}
}
// **A silence answers only its own condition** (ADR 0258): an action silencing another key is not offered,
// and a token for one is refused.
func TestASilenceAnswersOnlyItsOwnCondition(t *testing.T) {
other := Action{Label: "Silence another", Verb: "mesh-controller.conditions",
Arguments: map[string]string{"silence": "machine.ace.silent", "for": "7d", "why": ""}}
own := Action{Label: "Silence for a week", Verb: "mesh-controller.conditions",
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}}
h, dt, _, asked, _ := answering(t, other, own)
var labels []string
for _, a := range dt.sends[0].Actions {
labels = append(labels, a.Label)
}
if strings.Join(labels, ",") != "Silence for a week,Details" {
t.Fatalf("offered %v", labels)
}
h.mu.Lock()
h.open["machine.shanks.units"].Tokens["forged"] = Grant{Index: 0, Issued: h.Now()}
h.mu.Unlock()
if out, _ := h.Chosen("desktop", "g14", "forged", "e1"); out != "refused" || len(*asked) != 0 {
t.Fatalf("another condition was silenced: %q %v", out, *asked)
func TestAnAnswerReplayedLongAfterIsNotActedOn(t *testing.T) {
h, tg, _, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
old := choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now().Add(-time.Hour))
if out, _ := h.Chosen("telegram", old); out != "too old" || len(w.of("mesh-delivery")) != 0 {
t.Errorf("%s", out)
}
}
// **A record kept by the build before ADR 0258 loads**: its tokens were token -> index; it is read with
// them dropped, never skipped (the old tokens die, as they must).
func TestARecordKeptByTheBuildBeforeLoads(t *testing.T) {
old := `{"key":"machine.shanks.units","kind":"units","subject":"machine shanks","severity":"warning",` +
`"summary":"s","headline":"3 failed services on shanks","origin":"condition","more":"m",` +
`"raised":"2026-10-08T12:38:00Z","sent":{"desktop":"g14=7"},"count":1,` +
`"actions":[{"label":"Release","verb":"mesh-delivery.release","arguments":{"id":"x","why":""}}],` +
`"tokens":{"0123456789abcdef01234567":0,"89abcdef0123456789abcdef":-1}}`
var r Record
if err := json.Unmarshal([]byte(old), &r); err != nil {
t.Fatalf("the old record does not load: %v", err)
func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
asked := asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}
h.Link("telegram", asked)
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Link a Telegram account as you") {
t.Fatalf("no code on the desk: %+v", dt.sends)
}
if r.Key != "machine.shanks.units" || len(r.Tokens) != 0 || r.Sent["desktop"] != "g14=7" {
t.Fatalf("%+v", r)
code := strings.ReplaceAll(strings.TrimSuffix(strings.SplitAfter(dt.sends[0].Body, "before ")[1][7:14], "."), " ", "")
if last := tg.sends[len(tg.sends)-1]; last.Title != "Send the code shown on your desk" {
t.Errorf("telegram was told %q", last.Title)
}
// Loaded into a holder, its old answer is refused and nothing is called.
h, _, _, _, _ := newHolder(t)
asked := 0
h.Ask = func(string, any) (json.RawMessage, error) { asked++; return nil, nil }
st := &memStore{recs: map[string]Record{r.Key: r}}
h.Store = st
if err := h.Load(); err != nil {
if p := store.links["telegram"]; p.CodeHash == "" || strings.Contains(p.CodeHash, code) {
t.Errorf("the code is kept in clear: %+v", p)
}
// Another account sending the code is refused; a wrong code is refused; the right one links.
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code}); a.Accepted {
t.Error("another account linked with the code")
}
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: "000000x"}); a.Accepted {
t.Error("a wrong code linked")
}
a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"})
if !a.Accepted || !strings.Contains(a.Words, "approve from 13:00") {
t.Fatalf("the right code: %+v (code %q)", a, code)
}
id := store.ids[identityKey("telegram", "42")]
if id.ApproveFrom.Sub(id.Linked) != time.Hour {
t.Errorf("approves from %s, linked %s", id.ApproveFrom, id.Linked)
}
// Said on every channel.
for _, ch := range []*fakeChannel{tg, dt} {
if last := ch.sends[len(ch.sends)-1]; !strings.Contains(last.Title, "is now linked as you") {
t.Errorf("%s was not told: %q", ch.name, last.Title)
}
}
// A second account cannot link while one is.
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "666", Authenticated: true}})
if _, pending := store.links["telegram"]; pending {
t.Error("a second link was begun while one is linked")
}
// Unlinking is said everywhere, and the account's answers are refused from then.
if _, err := h.Unlink("telegram"); err != nil {
t.Fatal(err)
}
if out, _ := h.Chosen("desktop", "g14", "0123456789abcdef01234567", "e1"); out != "refused" || asked != 0 {
t.Fatalf("an old token was taken: %q %d", out, asked)
if len(store.ids) != 0 || !strings.Contains(tg.sends[len(tg.sends)-1].Title, "No Telegram account is linked") {
t.Errorf("not unlinked and said: %v %q", store.ids, tg.sends[len(tg.sends)-1].Title)
}
}
func TestALinkCodeExpiresAndFiveWrongTriesEndIt(t *testing.T) {
h, _, _, c, store, _ := newRouter(t)
jo := asks.Sender{Identity: "42", Authenticated: true}
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: jo})
for i := 0; i < LinkTries; i++ {
h.Prove("telegram", asks.Code{Sender: jo, Code: "nope"})
}
if _, pending := store.links["telegram"]; pending {
t.Error("five wrong codes did not end the link")
}
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: jo})
c.pass(LinkCodeFor)
if a := h.Prove("telegram", asks.Code{Sender: jo, Code: "123456"}); a.Accepted || !strings.Contains(a.Words, "expired") {
t.Errorf("%+v", a)
}
// The desk cannot be linked: it proves nothing about who clicked.
h.Link("desktop", asks.LinkAsked{ID: "l3", At: c.now(), Sender: asks.Sender{Identity: "g14"}})
if _, pending := store.links["desktop"]; pending {
t.Error("the desk began a link")
}
}
+721
View File
@@ -0,0 +1,721 @@
package main
// Asks and warrants (novox/hq ADR 0259): the router holds every ask, judges each answer and issues a
// warrant; the asker performs the option with its own grants. The router performs nothing.
//
// - **An ask arrives on the seat's work queue under its asker's name** (`accept.ask.<asker>`), 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 `<asker>.<id>` 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 `<asker>.<id>` in the bucket `asks`. Its asker reads
// it there under its own name.
type AskRecord struct {
Asker string `json:"asker"`
Ask asks.Ask `json:"ask"`
State string `json:"state"`
Opened time.Time `json:"opened"`
Closed time.Time `json:"closed,omitempty"`
Tickets map[string]Ticket `json:"tickets,omitempty"`
Warrant *asks.Warrant `json:"warrant,omitempty"`
// Told: the warrant, or the end, was published to the asker.
Told bool `json:"told,omitempty"`
// Record is the message the ask is shown on: a condition's key, or its own.
Record string `json:"record,omitempty"`
}
// Identity is one account linked as the operator on a channel kind.
type Identity struct {
Kind string `json:"kind"`
Identity string `json:"identity"`
Display string `json:"display,omitempty"`
Linked time.Time `json:"linked"`
ApproveFrom time.Time `json:"approve-from"`
}
// AskStore is the router's own state for asks and the operator's identities (ADR 0201).
type AskStore interface {
GetAsk(key string) (*AskRecord, uint64, error)
CreateAsk(key string, r AskRecord) (uint64, error)
UpdateAsk(key string, r AskRecord, revision uint64) (uint64, error)
AskKeys() ([]string, error)
GetIdentity(key string) (*Identity, error)
PutIdentity(key string, id Identity) error
DeleteIdentity(key string) error
IdentityKeys() ([]string, error)
GetLink(kind string) (*PendingLink, error)
PutLink(kind string, l PendingLink) error
DeleteLink(kind string) error
}
type askEntry struct {
rec AskRecord
rev uint64
}
func askKey(asker, id string) string { return asker + "." + id }
// loadAsks reads back every ask, and indexes the tickets of the open ones.
func (h *Holder) loadAsks() error {
if h.Asks == nil {
return nil
}
keys, err := h.Asks.AskKeys()
if err != nil {
return err
}
for _, k := range keys {
rec, rev, err := h.Asks.GetAsk(k)
if err != nil {
return err
}
if rec == nil {
continue
}
h.keepAsk(k, *rec, rev)
}
return nil
}
func (h *Holder) keepAsk(key string, rec AskRecord, rev uint64) {
h.mu.Lock()
defer h.mu.Unlock()
if h.asks == nil {
h.asks = map[string]*askEntry{}
h.tickets = map[string]string{}
}
h.asks[key] = &askEntry{rec: rec, rev: rev}
// Kept after the ask ends, so a late answer is told why it was not taken.
for t := range rec.Tickets {
h.tickets[t] = key
}
}
func (h *Holder) askOf(key string) *askEntry {
h.mu.Lock()
defer h.mu.Unlock()
if h.asks == nil {
return nil
}
return h.asks[key]
}
// openAsk is the ask a record shows, when it is still open.
func (h *Holder) openAsk(r *Record) *AskRecord {
if r == nil || r.Ask == "" {
return nil
}
e := h.askOf(r.Ask)
if e == nil || e.rec.State != StateOpen {
return nil
}
rec := e.rec
return &rec
}
// carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for
// approve verifying their sender with an account of the operator's linked.
func (h *Holder) carriers(level asks.Level) []string {
if h.Bench == nil {
return nil
}
var out []string
for _, k := range h.Bench.Kinds() {
if h.Bench.Ready(k.Kind) != nil || !h.Bench.Promises(k.Kind, "choice") {
continue
}
switch level {
case asks.Acknowledge:
out = append(out, k.Kind)
case asks.Approve:
if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) {
out = append(out, k.Kind)
}
}
// Destroy needs a code, which no channel carries yet.
}
return out
}
// TakeAsk takes one ask or cancel from the seat's queue. asker is the subject's last token, which the bus
// grants that module alone. An error is returned only when the ask could not be recorded, so the queue
// offers it again; a refusal is said to the asker and the work is taken.
func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
h.mu.Unlock()
if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) {
h.Logf("[messenger] an ask from %q was taken and dropped: not a module's name", asker)
return nil
}
if verb == "cancel" {
var c struct {
ID string `json:"id"`
}
if json.Unmarshal(body, &c) != nil || !asks.UsableID(c.ID) {
h.Logf("[messenger] a cancel from %s names no ask", asker)
return nil
}
return h.closeAsk(askKey(asker, c.ID), asks.OutcomeCancelled, "its asker took it back", nil, "")
}
var a asks.Ask
if err := json.Unmarshal(body, &a); err != nil {
h.refuseAsk(asker, asks.Ask{ID: "unreadable"}, "it could not be read: "+err.Error())
return nil
}
key := askKey(asker, a.ID)
if h.askOf(key) != nil {
return nil // a redelivery: already recorded
}
now := h.Now()
if err := a.Check(now); err != nil {
h.refuseAsk(asker, a, err.Error())
return nil
}
words := []string{a.Headline, a.Explanation, a.OnExpiry}
for _, o := range a.Options {
words = append(words, o.Label, o.Does)
}
if refusal, ok := Check(strings.Join(words, "\n")); !ok {
h.refuseAsk(asker, a, "its words carry "+refusal.String()+", which may not leave the mesh: move it into a reference or say it in words")
return nil
}
open, replaced := 0, ""
h.mu.Lock()
for k, e := range h.asks {
if e.rec.Asker != asker || e.rec.State != StateOpen {
continue
}
if a.About != "" && e.rec.Ask.About == a.About {
replaced = k
continue
}
open++
}
h.mu.Unlock()
if open >= asks.MostOpen {
h.refuseAsk(asker, a, fmt.Sprintf("its asker already has %d open asks", open))
return nil
}
rec := AskRecord{Asker: asker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{}}
for _, o := range a.Options {
for _, kind := range h.carriers(o.Level) {
rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: kind}
}
}
if len(rec.Tickets) == 0 {
h.refuseAsk(asker, a, "no channel can carry any of its answers now: "+h.whyNot())
return nil
}
if replaced != "" {
if err := h.closeAsk(replaced, asks.OutcomeReplaced, "a newer ask about the same thing replaced it", nil, ""); err != nil && !errors.Is(err, ErrChanged) {
return err
}
}
// Shown on the condition it is about, when that is open here; otherwise as a message of its own.
h.mu.Lock()
target := h.open["ask."+key]
if a.About != "" {
if r := h.open[a.About]; r != nil && r.Cleared.IsZero() {
target = r
}
}
h.mu.Unlock()
if target != nil {
rec.Record = target.Key
} else {
rec.Record = "ask." + key
}
if h.Asks != nil {
rev, err := h.Asks.CreateAsk(key, rec)
if errors.Is(err, ErrChanged) {
got, rev, err := h.Asks.GetAsk(key)
if err != nil || got == nil {
return fmt.Errorf("the ask %s is on record and cannot be read: %v", key, err)
}
h.keepAsk(key, *got, rev)
return nil
}
if err != nil {
return fmt.Errorf("the ask %s could not be recorded: %w", key, err)
}
h.keepAsk(key, rec, rev)
} else {
h.keepAsk(key, rec, 0)
}
h.Logf("[messenger] %s asks %q (%s), shown on %s", asker, a.Headline, a.ID, rec.Record)
if target != nil {
h.mu.Lock()
target.Ask = key
h.mu.Unlock()
if !target.silenced(now) {
h.offerAsk(target)
}
h.persist(target)
return nil
}
severity := Warning
if a.Urgent {
severity = Urgent
}
r := Record{Key: "ask." + key, Kind: "ask", Subject: asker, Severity: severity, Summary: a.Headline,
Headline: a.Headline, Explanation: a.Explanation, Origin: "ask", More: "operator-channel.asks",
Raised: now, Sent: map[string]string{}, Seen: now, Count: 1, Ask: key}
h.mu.Lock()
h.open[r.Key] = &r
h.mu.Unlock()
h.deliver(&r, "raised")
h.persist(&r)
return nil
}
// askKinds are the kinds an open ask about a record is offered on.
func (h *Holder) askKinds(r *Record) map[string]bool {
rec := h.openAsk(r)
if rec == nil {
return nil
}
out := map[string]bool{}
for _, t := range rec.Tickets {
out[t.Kind] = true
}
return out
}
// offerAsk shows a record's new ask: edited in place where the record was shown, a message of its own on a
// channel that carries the ask and has not shown the record.
func (h *Holder) offerAsk(r *Record) {
kinds := h.askKinds(r)
h.mu.Lock()
sent := map[string]string{}
for k, v := range r.Sent {
sent[k] = v
}
h.mu.Unlock()
now := h.Now()
for _, ch := range h.channels() {
id, shown := sent[ch.Name()]
switch {
case shown && id != "" && ch.CanEdit():
err := ch.Edit(id, h.sayOn(r, "asked", ch.Name()))
h.noteChannel(ch.Name(), err)
if err == nil {
h.record(Sent{At: now, Channel: ch.Name(), Key: r.Key, What: "asked", Outcome: "edited"})
continue
}
h.enqueue(ch.Name(), item{Key: r.Key, What: "asked", Severity: r.Severity, At: now})
case kinds[ch.Name()] && ch.Ready() == nil:
h.enqueue(ch.Name(), item{Key: r.Key, What: "asked", Severity: r.Severity, At: now})
}
}
h.flush(now, false)
}
// refuseAsk says to its asker that an ask was not taken, and why.
func (h *Holder) refuseAsk(asker string, a asks.Ask, why string) {
h.Logf("[messenger] refused an ask from %s (%s): %s", asker, a.ID, why)
h.tell(asks.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: asks.OutcomeRefused, Words: why, At: h.Now()})
}
// tell publishes a warrant, or an ask's end, to its asker alone, deduplicated by the ask.
func (h *Holder) tell(w asks.Warrant) error {
if h.Publish == nil {
return errors.New("this router cannot reach the bus")
}
err := h.Publish(asks.DecidedSubject(w.Asker), w, askKey(w.Asker, w.Ask)+"."+string(w.Outcome))
if err != nil {
h.Logf("[messenger] could not tell %s how its ask %s ended (%v); told again in a minute", w.Asker, w.Ask, err)
}
return err
}
// closeAsk ends an open ask by compare-and-set, tells its asker, and edits every copy. A second close
// loses: ErrChanged when another outcome was first.
func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *asks.Warrant, used string) error {
e := h.askOf(key)
if e == nil {
return nil
}
rec, rev := e.rec, e.rev
if h.Asks != nil {
got, r, err := h.Asks.GetAsk(key)
if err != nil {
return err
}
if got != nil {
rec, rev = *got, r
}
}
if rec.State != StateOpen {
h.keepAsk(key, rec, rev)
return ErrChanged
}
now := h.Now()
rec.State = string(outcome)
rec.Closed = now
if t, ok := rec.Tickets[used]; ok {
t.Used = true
rec.Tickets[used] = t
}
if w == nil {
w = &asks.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: outcome, Words: words, At: now}
}
rec.Warrant = w
if h.Asks != nil {
newRev, err := h.Asks.UpdateAsk(key, rec, rev)
if err != nil {
if errors.Is(err, ErrChanged) {
if got, r, gerr := h.Asks.GetAsk(key); gerr == nil && got != nil {
h.keepAsk(key, *got, r)
}
}
return err
}
rev = newRev
}
h.keepAsk(key, rec, rev)
if h.tell(*w) == nil {
h.markTold(key)
}
h.showClosed(rec)
return nil
}
// markTold records that the asker was told.
func (h *Holder) markTold(key string) {
e := h.askOf(key)
if e == nil {
return
}
rec, rev := e.rec, e.rev
rec.Told = true
if h.Asks != nil {
newRev, err := h.Asks.UpdateAsk(key, rec, rev)
if err != nil {
return // read again and told again by the sweep
}
rev = newRev
}
h.keepAsk(key, rec, rev)
}
// showClosed edits every copy of an ask's message to say how it ended, its buttons gone.
func (h *Holder) showClosed(rec AskRecord) {
h.mu.Lock()
r := h.open[rec.Record]
h.mu.Unlock()
if r == nil {
return
}
h.mu.Lock()
switch {
case rec.Warrant != nil && rec.Warrant.Outcome == asks.OutcomeChosen:
r.Answered = rec.Warrant.Label + ": chosen at " + rec.Closed.In(h.zone()).Format("15:04") + " on " + Display(rec.Warrant.By.Kind)
case rec.State == string(asks.OutcomeExpired):
r.Answered = "No longer answerable: nobody answered in time. " + capital(rec.Ask.OnExpiry)
case rec.State == string(asks.OutcomeReplaced):
r.Answered = ""
default:
r.Answered = "No longer needed."
}
ownMessage := r.Origin == "ask"
h.mu.Unlock()
if r.told() {
h.edit(r, "answered", false)
}
if ownMessage && rec.State != string(asks.OutcomeReplaced) {
h.mu.Lock()
r.Cleared = h.Now()
h.mu.Unlock()
}
h.persist(r)
}
// sweepAsks ends asks past their expiry, and tells again an asker not yet told. Called every minute.
func (h *Holder) sweepAsks(now time.Time) {
h.mu.Lock()
var expired, untold []string
for k, e := range h.asks {
switch {
case e.rec.State == StateOpen && !now.Before(e.rec.Ask.Expires):
expired = append(expired, k)
case e.rec.State != StateOpen && !e.rec.Told && e.rec.Warrant != nil:
untold = append(untold, k)
}
}
h.mu.Unlock()
sort.Strings(expired)
for _, k := range expired {
e := h.askOf(k)
if err := h.closeAsk(k, asks.OutcomeExpired, "nobody answered in time: "+e.rec.Ask.OnExpiry, nil, ""); err != nil && !errors.Is(err, ErrChanged) {
h.Logf("[messenger] could not end the expired ask %s: %v", k, err)
}
}
for _, k := range untold {
e := h.askOf(k)
if e != nil && h.tell(*e.rec.Warrant) == nil {
h.markTold(k)
}
}
}
// offers are an open ask's buttons on one kind: each option offered there, by its ticket.
func (h *Holder) offers(rec *AskRecord, kind string) []Choice {
var out []Choice
for _, o := range rec.Ask.Options {
for t, tk := range rec.Tickets {
if tk.Kind == kind && tk.Option == o.ID && !tk.Used {
out = append(out, Choice{Token: t, Label: o.Label})
}
}
}
return out
}
// elsewhere is the line an ask's message carries on a kind for the options it cannot carry there: where
// else each can be given — "Release or Stop: answer on Telegram, or from the mesh MCP server."
func (h *Holder) elsewhere(rec *AskRecord, kind string) string {
var labels []string
where := map[string]bool{}
for _, o := range rec.Ask.Options {
here := false
for _, tk := range rec.Tickets {
if tk.Option == o.ID && tk.Kind == kind {
here = true
}
}
if here {
continue
}
labels = append(labels, o.Label)
for _, tk := range rec.Tickets {
if tk.Option == o.ID && tk.Kind != kind {
where[tk.Kind] = true
}
}
}
if len(labels) == 0 {
return ""
}
var kinds []string
for k := range where {
kinds = append(kinds, Display(k))
}
sort.Strings(kinds)
answer := "from the mesh MCP server"
if len(kinds) > 0 {
answer = "answer on " + strings.Join(kinds, " or ") + ", or from the mesh MCP server"
}
return joinOr(labels) + ": " + answer + "."
}
func joinOr(labels []string) string {
if len(labels) <= 1 {
return strings.Join(labels, "")
}
return strings.Join(labels[:len(labels)-1], ", ") + " or " + labels[len(labels)-1]
}
// judge is an answer to an ask's option, chosen on a kind: refused at the first failure, in words said on
// the kind; otherwise the ask closed and the warrant published (ADR 0259 §5).
func (h *Holder) judge(kind string, c asks.Choice, key string) (string, error) {
e := h.askOf(key)
if e == nil {
return h.refuseChoice(kind, c, "it belongs to no ask this router holds")
}
rec := e.rec
if h.Asks != nil {
if got, _, err := h.Asks.GetAsk(key); err == nil && got != nil {
rec = *got
}
}
now := h.Now()
t, issued := rec.Tickets[c.Ticket]
switch {
case !issued:
return h.refuseChoice(kind, c, "it belongs to no ask this router holds")
case t.Kind != kind:
return h.refuseChoice(kind, c, "it was not offered here")
case rec.State != StateOpen:
return h.refuseChoice(kind, c, "the question was already answered or has ended")
case t.Used:
return h.refuseChoice(kind, c, "it was already chosen once")
case !now.Before(rec.Ask.Expires):
return h.refuseChoice(kind, c, "the question expired at "+rec.Ask.Expires.In(h.zone()).Format("15:04"))
}
o, offered := rec.Ask.Option(t.Option)
if !offered {
return h.refuseChoice(kind, c, "it names an answer the question does not offer")
}
info, held := h.Bench.Kind(kind)
if !held || !h.Bench.Promises(kind, "choice") {
return h.refuseChoice(kind, c, "this channel is not one the mesh holds for answers")
}
person := asks.Person{Who: asks.Operator, Kind: kind, Identity: c.Sender.Identity, Display: c.Sender.Display}
verifies := h.Bench.Promises(kind, "verified-sender")
var id *Identity
if verifies {
if !c.Sender.Authenticated {
return h.refuseChoice(kind, c, "the service did not authenticate who sent it")
}
id = h.identity(kind, c.Sender.Identity)
if id == nil {
return h.refuseChoice(kind, c, "this account is not linked as the operator")
}
person.Verified = "user id verified"
} else {
person.Verified = "a desk click: whoever was at the operator's session on " + c.Sender.Identity
}
proofs := []string{}
switch o.Level {
case asks.Acknowledge:
case asks.Approve:
if !verifies {
return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it can only acknowledge")
}
if now.Before(id.ApproveFrom) {
return h.refuseChoice(kind, c, "this account was linked at "+id.Linked.In(h.zone()).Format("15:04")+
" and can approve from "+id.ApproveFrom.In(h.zone()).Format("15:04"))
}
proofs = append(proofs, "P1")
default:
return h.refuseChoice(kind, c, "it needs a code from the authenticator app, which the mesh does not take yet")
}
w := &asks.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: asks.OutcomeChosen,
Option: o.ID, Label: o.Label, Level: o.Level, By: &person, Channel: info.Module, Proofs: proofs, At: now}
if err := h.closeAsk(key, asks.OutcomeChosen, "", w, c.Ticket); err != nil {
if errors.Is(err, ErrChanged) {
return h.refuseChoice(kind, c, "the question was already answered")
}
return h.refuseChoice(kind, c, "it could not be recorded: "+err.Error())
}
h.record(Sent{At: now, Channel: kind, Key: rec.Record, What: "answer " + o.Label, Outcome: "warrant to " + rec.Asker})
h.Logf("[messenger] %s: a warrant for %s's ask %s", w.Says(), rec.Asker, rec.Ask.ID)
return "chosen", nil
}
// refuseChoice says on the kind that an answer was not taken, and why.
func (h *Holder) refuseChoice(kind string, c asks.Choice, why string) (string, error) {
h.Logf("[messenger] an answer on %s from %s was refused: %s", kind, c.Sender.Identity, why)
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "answer", Outcome: "refused: " + why})
h.showOn(kind, Message{Title: "That answer was not taken", Body: capital(why) + ". Nothing was done.",
Quiet: true, Reply: c.ID})
return "refused", errorf("%s", why)
}
// showOn says a message of the router's own on one kind, at once: an answer's refusal, details, a link's code.
func (h *Holder) showOn(kind string, m Message) error {
for _, ch := range h.channels() {
if ch.Name() == kind {
_, err := ch.Send(m)
h.noteChannel(kind, err)
return err
}
}
if h.Publish == nil {
return errors.New("this router cannot reach the bus")
}
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
if h.Bench != nil && h.Bench.To != nil {
out.To = h.Bench.To(kind)
}
err := h.Publish(asks.ChannelSubject(asks.Send, kind), out, out.Handle)
h.noteChannel(kind, err)
return err
}
func ticket() string {
var b [16]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// AskView is one ask as the tool `asks` answers it.
type AskView struct {
Key string `json:"key"`
Asker string `json:"asker"`
Headline string `json:"headline"`
State string `json:"state"`
Opened string `json:"opened"`
Expires string `json:"expires"`
Offered []string `json:"offered_on,omitempty"`
Answer string `json:"answer,omitempty"`
Told bool `json:"asker_told"`
}
// AsksNow is every ask, open first, newest first.
func (h *Holder) AsksNow() []AskView {
h.mu.Lock()
defer h.mu.Unlock()
var out []AskView
for k, e := range h.asks {
v := AskView{Key: k, Asker: e.rec.Asker, Headline: e.rec.Ask.Headline, State: e.rec.State,
Opened: e.rec.Opened.UTC().Format(time.RFC3339), Expires: e.rec.Ask.Expires.UTC().Format(time.RFC3339),
Told: e.rec.Told}
kinds := map[string]bool{}
for _, t := range e.rec.Tickets {
kinds[t.Kind] = true
}
for k := range kinds {
v.Offered = append(v.Offered, k)
}
sort.Strings(v.Offered)
if w := e.rec.Warrant; w != nil {
if w.Outcome == asks.OutcomeChosen {
v.Answer = w.Says()
} else {
v.Answer = string(w.Outcome) + ": " + w.Words
}
}
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool {
if (out[i].State == StateOpen) != (out[j].State == StateOpen) {
return out[i].State == StateOpen
}
return out[i].Opened > out[j].Opened
})
return out
}
@@ -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 &lt;b&gt;" || 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 <b>", 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 <b>", 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)
}
}
-186
View File
@@ -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\": [\"<the machine the operator sits at>\"]})")
}
return nil
}
// Send shows the message on every configured machine that has a session, and answers
// `<machine>=<id>` 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))
}
@@ -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)
}
+57 -13
View File
@@ -89,6 +89,8 @@ type Message struct {
Urgent bool
Quiet bool // a clearing: shown without urgency
Actions []Choice
// Reply is the answer this message answers, by its id: the channel shows it where that was given.
Reply string
}
// Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label.
@@ -145,7 +147,10 @@ type Record struct {
// nothing stays silent for ever.
QuietReminded bool `json:"quiet_reminded,omitempty"`
// Answered is what the operator chose from a notification, and what came of it.
Answered string `json:"answered,omitempty"`
Answered string `json:"answered,omitempty"`
// Ask is the open ask about this record, `<asker>.<id>`: its options are this record's answers
// (novox/hq ADR 0259).
Ask string `json:"ask,omitempty"`
Origin string `json:"origin"`
More string `json:"more"`
Raised time.Time `json:"raised"`
@@ -212,13 +217,20 @@ type Store interface {
Recent() ([]Sent, error)
}
// Holder is the seat's holder.
// Holder is the seat's holder: the router (novox/hq ADR 0234 §5, ADR 0259). It keeps no channel's client:
// Away is the channel that reaches the operator away from the machines (a kind promising reaches-away),
// Desk the one at the machines; each is a kind of the `channel` bench, held by a module of its own.
type Holder struct {
Telegram Channel
Desktop Channel
Store Store
Now func() time.Time
Logf func(string, ...any)
Away Channel
Desk Channel
Store Store
// Asks keeps the asks, the operator's identities and their links (asks.go, link.go); Bench is what
// the controller says of every channel kind and what each says of itself; Publish says on a seat.
Asks AskStore
Bench *Bench
Publish func(subject string, body any, id string) error
Now func() time.Time
Logf func(string, ...any)
// Emit states a fact as this module (refused); nil states nothing.
Emit func(event string, body any) error
// Ask calls a seat verb through the mesh, as this module: the answers the operator chooses.
@@ -255,6 +267,9 @@ type Holder struct {
stale int
// answered is what came of each answer event, by its id: a redelivered one is acted on once.
answered map[string]string
// asks are the asks this router holds, by `<asker>.<id>`, and tickets each ask's tickets to it.
asks map[string]*askEntry
tickets map[string]string
}
func (h *Holder) init() {
@@ -308,7 +323,10 @@ func (h *Holder) Load() error {
if sent, err := h.Store.Recent(); err == nil {
h.recent = sent
}
return nil
h.mu.Unlock()
err = h.loadAsks()
h.mu.Lock()
return err
}
// Start marks the holder's start: the grace minute counts from here.
@@ -532,6 +550,7 @@ func (h *Holder) Tick() {
}
h.mu.Unlock()
sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) })
h.sweepAsks(now)
for _, r := range recs {
switch {
case !r.Cleared.IsZero():
@@ -592,7 +611,35 @@ func remindAfter(severity string) time.Duration {
// say composes a record's message under the content rule: refused, it is composed again with less of
// it, until what remains may leave. The refusal is recorded and stated once per record.
func (h *Holder) say(r *Record, what string) Message {
func (h *Holder) say(r *Record, what string) Message { return h.sayOn(r, what, "") }
// sayOn is a record's message on one channel kind: its words, what came of an answer, the answers that kind
// can carry, and where the others can be given. An empty kind is the words alone.
func (h *Holder) sayOn(r *Record, what, kind string) Message {
m := h.words(r, what)
if what == "cleared" || m.Title == "A mesh message was withheld" {
return m
}
if r.Answered != "" {
m.Body = strings.TrimSpace(r.Answered + "\n" + m.Body)
}
if kind == "" {
return m
}
if rec := h.openAsk(r); rec != nil {
if line := h.elsewhere(rec, kind); line != "" {
if _, ok := Check(line); ok {
m.Body = strings.TrimSpace(m.Body + "\n" + line)
}
}
}
m.Actions = h.choices(r, kind)
return m
}
// words is a record's message under the content rule: refused, it is composed again with less of it,
// until what remains may leave. The refusal is recorded and stated once per record.
func (h *Holder) words(r *Record, what string) Message {
now := h.Now()
if r.Refused != "" {
// Refused before: its words stay withheld in every later message too.
@@ -606,9 +653,6 @@ func (h *Holder) say(r *Record, what string) Message {
m := compose(r, what, now, h.zone(), 0)
refusal, ok := Check(m.Text())
if ok {
if what != "cleared" {
m.Actions = h.choices(r)
}
return m
}
h.mu.Lock()
@@ -643,7 +687,7 @@ func (h *Holder) say(r *Record, what string) Message {
func (h *Holder) channels() []Channel {
var out []Channel
for _, c := range []Channel{h.Telegram, h.Desktop} {
for _, c := range []Channel{h.Away, h.Desk} {
if c != nil {
out = append(out, c)
}
@@ -108,7 +108,7 @@ func newHolder(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memS
tg, dt := &fakeChannel{name: "telegram", silent: true}, &fakeChannel{name: "desktop"}
st := &memStore{}
var emitted []string
h := &Holder{Telegram: tg, Desktop: dt, Store: st, Now: c.now, Logf: t.Logf,
h := &Holder{Away: tg, Desk: dt, Store: st, Now: c.now, Logf: t.Logf,
Emit: func(e string, _ any) error { emitted = append(emitted, e); return nil }}
h.init()
return h, tg, dt, c, st
@@ -163,7 +163,7 @@ func TestAWarningGoesToTheDesktopWhenASessionAnswersElseTelegram(t *testing.T) {
func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
// Urgent conditions, each alone and after a quiet half minute: each is its own message — until
// the cap, which is said once, in a message of its own, and holds the rest.
for i := 0; i < 25; i++ {
@@ -241,7 +241,7 @@ func TestAMessageCarryingAnAddressIsRefusedAndSaidWithItsWordsWithheld(t *testin
func TestAKeyThatCarriesAnAddressIsWithheldToo(t *testing.T) {
h, tg, _, _, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
h.Condition(EventRaised, cond("machine.10.0.0.7.silent", Urgent, "a machine is silent"))
if len(tg.sends) != 1 || strings.Contains(tg.sends[0].Text(), "10.0.0.7") {
t.Fatalf("sends: %+v", tg.sends)
@@ -250,7 +250,7 @@ func TestAKeyThatCarriesAnAddressIsWithheldToo(t *testing.T) {
func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
u := cond("bus.controller.slow-consumer", Urgent, "the controller's consumer is far behind")
u.Raised = c.now()
w := cond("plan.41.stalled", Warning, "plan 41 waits")
@@ -365,7 +365,7 @@ func TestEscalationIsSaidOnce(t *testing.T) {
func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
tg.fail = errors.New("telegram sendMessage: cannot connect (dial)")
h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "silent"))
st := h.Status("listening")
@@ -396,11 +396,11 @@ func TestNothingConfiguredIsSaidInTheStatus(t *testing.T) {
func TestARestartForgetsNothing(t *testing.T) {
h, tg, _, c, st := newHolder(t)
h.Desktop = nil
h.Desk = nil
k := cond("machine.ace.silent", Urgent, "silent")
k.Raised = c.now()
h.Condition(EventRaised, k)
again := &Holder{Telegram: tg, Store: st, Now: c.now, Logf: t.Logf}
again := &Holder{Away: tg, Store: st, Now: c.now, Logf: t.Logf}
if err := again.Load(); err != nil {
t.Fatal(err)
}
@@ -416,7 +416,7 @@ func TestARestartForgetsNothing(t *testing.T) {
func TestAnUnreadableEventIsToldOnceAnHour(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
h.Unreadable("mesh-controller.condition-raised", errors.New("no severity"))
h.Unreadable("mesh-controller.condition-raised", errors.New("no severity"))
settle(h, c)
@@ -433,7 +433,7 @@ func TestAnUnreadableEventIsToldOnceAnHour(t *testing.T) {
func TestNotifyIsKeptApartFromConditions(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
out, err := h.Notify("backup.ace.failed", Warning, "last night's backup of the home server failed", "backup", false)
if err != nil || out["held"] != true {
t.Fatalf("%v %v", out, err)
+264
View File
@@ -0,0 +1,264 @@
package main
// Who the operator is on a channel (novox/hq ADR 0259 §7): the router's list of the operator's
// identities, one per kind, changed only by linking and unlinking — never by a setting, which any
// principal granted `settings` could write.
//
// - **Linking:** the operator sends the channel's bot `/start`; the channel says `link.<kind>` 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.<kind>`: request and reply, never kept). When it matches, the account is linked,
// and said on every channel.
// - **A new link acknowledges at once and approves only after an hour** (LinkedApproveAfter), decided
// by the operator on 2026-10-08 until an authenticator-app code exists: the hour lets the operator
// unlink an account an agent linked by reading the code off the X11 desk.
// - **One identity per kind.** A second link of a kind is refused until the first is unlinked; unlinking
// is said on every channel, the unlinked one included.
// - Only a kind whose claim declares `verified-sender` can be linked: the desk proves nothing about who
// clicked, so it has no identities and answers acknowledgements only.
// - The code is kept only as a salted hash; five wrong tries end the link asked for.
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
const (
// LinkCodeFor is how long a link's code is good for.
LinkCodeFor = 10 * time.Minute
// LinkTries is how many wrong codes end a link asked for.
LinkTries = 5
)
// PendingLink is a link asked for and not yet confirmed: who asked, and the code's hash.
type PendingLink struct {
Kind string `json:"kind"`
Identity string `json:"identity"`
Display string `json:"display,omitempty"`
Salt string `json:"salt"`
CodeHash string `json:"code-hash"`
Expires time.Time `json:"expires"`
Tries int `json:"tries"`
}
func identityKey(kind, identity string) string { return kind + "." + kvKey(identity) }
func hashCode(salt, code string) string {
sum := sha256.Sum256([]byte(salt + ":" + code))
return hex.EncodeToString(sum[:])
}
// identity is the linked account of a kind, when this sender is it.
func (h *Holder) identity(kind, identity string) *Identity {
if h.Asks == nil || identity == "" {
return nil
}
id, err := h.Asks.GetIdentity(identityKey(kind, identity))
if err != nil || id == nil || id.Kind != kind || id.Identity != identity {
return nil
}
return id
}
// identitiesOf is every account linked on a kind.
func (h *Holder) identitiesOf(kind string) []Identity {
if h.Asks == nil {
return nil
}
keys, err := h.Asks.IdentityKeys()
if err != nil {
return nil
}
var out []Identity
for _, k := range keys {
if !strings.HasPrefix(k, kind+".") {
continue
}
if id, err := h.Asks.GetIdentity(k); err == nil && id != nil && id.Kind == kind {
out = append(out, *id)
}
}
return out
}
func (h *Holder) linked(kind string) bool { return len(h.identitiesOf(kind)) > 0 }
// Link takes a link asked for on a kind: a code shown on the desk, or a refusal said on the kind.
func (h *Holder) Link(kind string, l asks.LinkAsked) string {
h.work.Lock()
defer h.work.Unlock()
reply := func(title, body string) string {
_ = h.showOn(kind, Message{Title: title, Body: body, Quiet: true, Reply: l.ID})
return title
}
if h.Bench == nil || !h.Bench.Promises(kind, "verified-sender") {
return reply("This channel cannot be linked", "It cannot prove who sends to it, so it answers only what anyone at the desk could.")
}
if !l.Sender.Authenticated || l.Sender.Identity == "" {
return reply("Not linked", "The service did not say who you are.")
}
if now := h.Now(); now.Sub(l.At) > FreshFor {
h.Logf("[messenger] a link asked on %s at %s is too old to act on", kind, l.At.Format(time.RFC3339))
return "too old"
}
for _, id := range h.identitiesOf(kind) {
if id.Identity == l.Sender.Identity {
return reply("Already linked", "This account is already linked as the operator.")
}
return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked from the mesh MCP server (messenger_unlink), and then this one can be linked.")
}
if h.Desk == nil || h.Desk.Ready() != nil {
return reply("Not linked", "No desk can show the code now. Try again when you are at your machine.")
}
code, err := sixDigits()
if err != nil {
return reply("Not linked", "No code could be made.")
}
salt := ticket()
p := PendingLink{Kind: kind, Identity: l.Sender.Identity, Display: l.Sender.Display, Salt: salt,
CodeHash: hashCode(salt, code), Expires: h.Now().Add(LinkCodeFor)}
if err := h.Asks.PutLink(kind, p); err != nil {
return reply("Not linked", "The link could not be recorded: "+err.Error())
}
who := l.Sender.Display
if who == "" {
who = "an account"
}
desk := Message{Title: "Link a " + Display(kind) + " account as you",
Body: fmt.Sprintf("The %s account %q asked to be linked as you. If that is you, send this code to the bot "+
"before %s: %s %s. If it is not you, do nothing: nothing is linked.", Display(kind), who,
p.Expires.In(h.zone()).Format("15:04"), code[:3], code[3:]),
Urgent: true}
if _, err := h.Desk.Send(desk); err != nil {
_ = h.Asks.DeleteLink(kind)
return reply("Not linked", "The desk could not show the code: "+err.Error())
}
h.Logf("[messenger] a link was asked on %s by %q; its code is shown on the desk", kind, who)
return reply("Send the code shown on your desk", "Six digits, within ten minutes.")
}
// Prove answers a code sent on a kind: the link it confirms, or why not. Never said in an event.
func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
h.work.Lock()
defer h.work.Unlock()
no := func(words string) asks.ProofAnswer { return asks.ProofAnswer{Accepted: false, Words: words} }
if c.Purpose != "" && c.Purpose != "link" {
return no("The mesh takes no code for that yet.")
}
if h.Asks == nil {
return no("Nothing can be linked here.")
}
p, err := h.Asks.GetLink(kind)
if err != nil || p == nil {
return no("No link was asked for. Send /start to begin.")
}
now := h.Now()
if !now.Before(p.Expires) {
_ = h.Asks.DeleteLink(kind)
return no("The code expired. Send /start for a new one.")
}
if c.Sender.Identity != p.Identity || !c.Sender.Authenticated {
return no("This code was asked for by another account.")
}
code := strings.ReplaceAll(strings.TrimSpace(c.Code), " ", "")
if subtle.ConstantTimeCompare([]byte(hashCode(p.Salt, code)), []byte(p.CodeHash)) != 1 {
p.Tries++
if p.Tries >= LinkTries {
_ = h.Asks.DeleteLink(kind)
h.Logf("[messenger] a link on %s ended after %d wrong codes", kind, p.Tries)
return no("That code is not right, and that was the last try. Send /start for a new one.")
}
_ = h.Asks.PutLink(kind, *p)
return no("That code is not right.")
}
id := Identity{Kind: kind, Identity: p.Identity, Display: p.Display, Linked: now, ApproveFrom: now.Add(LinkedApproveAfter)}
if err := h.Asks.PutIdentity(identityKey(kind, p.Identity), id); err != nil {
return no("The link could not be recorded: " + err.Error())
}
_ = h.Asks.DeleteLink(kind)
from := id.ApproveFrom.In(h.zone()).Format("15:04")
h.announce(Message{Title: "A " + Display(kind) + " account is now linked as you",
Body: fmt.Sprintf("The %s account %q can answer acknowledgements now, and approve from %s. If this was not "+
"you, unlink it from the mesh MCP server (messenger_unlink) before then.", Display(kind), p.Display, from),
Urgent: true})
h.record(Sent{At: now, Channel: kind, Key: "(link)", What: "linked " + p.Display, Outcome: "linked"})
h.Logf("[messenger] linked %q on %s; it approves from %s", p.Display, kind, from)
return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
}
// Unlink removes every account linked on a kind, said on every channel.
func (h *Holder) Unlink(kind string) (map[string]any, error) {
h.work.Lock()
defer h.work.Unlock()
if h.Asks == nil {
return nil, errorf("nothing is linked here")
}
ids := h.identitiesOf(kind)
if len(ids) == 0 {
return map[string]any{"kind": kind, "unlinked": []string{}}, nil
}
var names []string
for _, id := range ids {
if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil {
return nil, err
}
names = append(names, id.Display)
}
h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more",
Body: "Unlinked: " + strings.Join(names, ", ") + ". Answers that need you to be proven cannot be given on " +
Display(kind) + " until an account is linked again.", Urgent: true})
h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: strings.Join(names, ", ")})
return map[string]any{"kind": kind, "unlinked": names}, nil
}
// Identities is every linked account, by kind.
func (h *Holder) Identities() []Identity {
if h.Asks == nil {
return nil
}
keys, _ := h.Asks.IdentityKeys()
var out []Identity
for _, k := range keys {
if id, err := h.Asks.GetIdentity(k); err == nil && id != nil {
out = append(out, *id)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Kind+out[i].Identity < out[j].Kind+out[j].Identity })
return out
}
// announce says a message on every kind that can send, at once: a link or an unlink is seen everywhere.
func (h *Holder) announce(m Message) {
if h.Bench == nil {
return
}
for _, k := range h.Bench.Kinds() {
if h.Bench.Ready(k.Kind) == nil {
_ = h.showOn(k.Kind, m)
}
}
}
func sixDigits() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// decodeLink reads a link asked for.
func decodeLink(body json.RawMessage) (asks.LinkAsked, error) {
var l asks.LinkAsked
return l, json.Unmarshal(body, &l)
}
+238 -92
View File
@@ -1,7 +1,7 @@
// messenger: the holder of the operator-channel seat (novox/hq to-be 45 §5, ADR 0227, research 028).
// A Go bundle the node's runtime launches. It consumes the controller's condition events and decides
// what is said to the operator, on Telegram and on the desktop notifier of the machine the operator
// is at. It keeps its open messages in its own state, so a restart forgets nothing. stdout is the MCP
// messenger: the holder of the operator-channel seat, the router (novox/hq to-be 45 §5, to-be 46, ADR
// 0227, ADR 0259). A Go bundle the node's runtime launches. It consumes the controller's condition events and
// decides what is said to the operator, on the channel kinds the mesh holds — each a module of its own — and
// holds every ask, judges each answer and issues the warrant its asker acts on. It keeps its open messages in its own state, so a restart forgets nothing. stdout is the MCP
// channel; what this module says, it says on stderr.
package main
@@ -17,6 +17,7 @@ import (
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
@@ -25,8 +26,6 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
// Settings are the operator's values for this module, merged by the mesh into one JSON file.
type Settings struct {
TelegramChatID string `json:"telegram-chat-id"`
DesktopMachines []string `json:"desktop-machines"`
// TimeZone is the operator's, an IANA name such as Europe/Brussels: every time a message says is said
// in it. Empty is this machine's own zone.
TimeZone string `json:"time-zone"`
@@ -54,27 +53,13 @@ func readSettings(path string) (Settings, error) {
if err != nil {
return s, err
}
// The chat id may be given as a number.
var loose map[string]any
if err := json.Unmarshal(raw, &loose); err != nil {
return s, fmt.Errorf("the settings file is not JSON: %v", err)
}
switch v := loose["telegram-chat-id"].(type) {
case string:
s.TelegramChatID = strings.TrimSpace(v)
case float64:
s.TelegramChatID = fmt.Sprintf("%.0f", v)
}
if tz, ok := loose["time-zone"].(string); ok {
s.TimeZone = strings.TrimSpace(tz)
}
if list, ok := loose["desktop-machines"].([]any); ok {
for _, m := range list {
if name, ok := m.(string); ok && strings.TrimSpace(name) != "" {
s.DesktopMachines = append(s.DesktopMachines, strings.TrimSpace(name))
}
}
}
return s, nil
}
@@ -137,6 +122,77 @@ func (stateStore) Recent() ([]Sent, error) {
return s, json.Unmarshal(e.Value, &s)
}
// askStore keeps the asks in the state `asks` and the operator's identities, and the links asked for, in
// `identities` (novox/hq ADR 0259). Each write that must win once is a compare-and-set.
type askStore struct{}
// changed reads the runtime's refusal of a compare-and-set that lost as ErrChanged.
func changed(err error) error {
if err != nil && strings.Contains(err.Error(), "written by another writer first") {
return ErrChanged
}
return err
}
func (askStore) GetAsk(key string) (*AskRecord, uint64, error) {
e, err := stdio.State("asks").Get(key)
if err != nil || e == nil {
return nil, 0, err
}
var r AskRecord
if err := json.Unmarshal(e.Value, &r); err != nil {
return nil, 0, fmt.Errorf("the ask kept as %s cannot be read: %v", key, err)
}
return &r, e.Revision, nil
}
func (askStore) CreateAsk(key string, r AskRecord) (uint64, error) {
rev, err := stdio.State("asks").Create(key, r)
return rev, changed(err)
}
func (askStore) UpdateAsk(key string, r AskRecord, revision uint64) (uint64, error) {
rev, err := stdio.State("asks").Update(key, r, revision)
return rev, changed(err)
}
func (askStore) AskKeys() ([]string, error) { return stdio.State("asks").Keys() }
func (askStore) GetIdentity(key string) (*Identity, error) {
e, err := stdio.State("identities").Get(key)
if err != nil || e == nil {
return nil, err
}
var id Identity
return &id, json.Unmarshal(e.Value, &id)
}
func (askStore) PutIdentity(key string, id Identity) error {
_, err := stdio.State("identities").Put(key, id)
return err
}
func (askStore) DeleteIdentity(key string) error { return stdio.State("identities").Delete(key) }
func (askStore) IdentityKeys() ([]string, error) {
keys, err := stdio.State("identities").Keys()
var out []string
for _, k := range keys {
if !strings.HasPrefix(k, "pending.") {
out = append(out, k)
}
}
return out, err
}
func (askStore) GetLink(kind string) (*PendingLink, error) {
e, err := stdio.State("identities").Get("pending." + kind)
if err != nil || e == nil {
return nil, err
}
var l PendingLink
return &l, json.Unmarshal(e.Value, &l)
}
func (askStore) PutLink(kind string, l PendingLink) error {
_, err := stdio.State("identities").Put("pending."+kind, l)
return err
}
func (askStore) DeleteLink(kind string) error {
return stdio.State("identities").Delete("pending." + kind)
}
// listening is whether condition events reach this holder, in words.
type listening struct {
mu sync.Mutex
@@ -163,18 +219,17 @@ func main() {
}
return s
}
bench := NewBench(time.Now)
publish := func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) }
h := &Holder{
Telegram: NewTelegram(TelegramConfig{
TokenFile: os.Getenv("MESH_MESSENGER_TELEGRAM_TOKEN_FILE"),
ChatID: func() string { return settings().TelegramChatID },
}),
Desktop: &Desktop{
Machines: func() []string { return settings().DesktopMachines },
Ask: stdio.Ask,
},
Store: stateStore{},
Ask: stdio.Ask,
Logf: logf,
Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish},
Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish},
Store: stateStore{},
Asks: askStore{},
Bench: bench,
Publish: publish,
Ask: stdio.Ask,
Logf: logf,
Zone: func() *time.Location {
loc, err := zoneOf(settings().TimeZone)
said.Lock()
@@ -190,6 +245,12 @@ func main() {
},
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
}
bench.To = func(kind string) string {
if ids := h.identitiesOf(kind); len(ids) > 0 {
return ids[0].Identity
}
return ""
}
h.init()
l := &listening{now: "not yet: starting"}
go run(h, l)
@@ -250,7 +311,10 @@ func run(h *Holder, l *listening) {
h.Condition(event, c)
return nil
}
go listenForAnswers(h)
go listenForChannels(h)
go takeAsks(h)
go answerProofs(h)
go followKinds(h)
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(ControllerSeat+".*", handle)
if err == nil {
@@ -264,61 +328,137 @@ func run(h *Holder, l *listening) {
}
}
// AnswerEvent is the desk's notifier saying which answer was chosen, and StartedEvent that it started (so
// the answers it waited for are lost): the dunst holder of node-notifier emits them as its module's events
// until seats publish events of their own (to-be 46 phase 3).
const (
AnswerEvent = "dunst.action-chosen"
StartedEvent = "dunst.started"
)
// kindOf is an intake event's kind and what it is: `intake.<what>.<kind>`, the kind the subject's last token,
// which the bus grants that kind's holder alone.
func kindOf(key string) (what, kind string, ok bool) {
parts := strings.Split(key, ".")
if len(parts) != 3 || parts[0] != asks.IntakeSeat {
return "", "", false
}
return parts[1], parts[2], true
}
// listenForAnswers takes the answers the operator chooses on the desk, retried until it can.
func listenForAnswers(h *Holder) {
// listenForChannels takes what the channels say (novox/hq ADR 0259): answers chosen, links asked, messages
// that failed, and whether each can send — every one under the kind the bus granted its holder.
func listenForChannels(h *Holder) {
handle := func(e stdio.Envelope) error {
if e.Key == StartedEvent {
var body struct {
Machine string `json:"machine"`
what, kind, ok := kindOf(e.Key)
if !ok {
return nil
}
switch what {
case asks.Chosen:
var c asks.Choice
if err := json.Unmarshal(e.Body, &c); err != nil || c.Ticket == "" {
logf("[messenger] an answer from %s could not be read: %s", kind, firstLineOf(string(e.Body)))
return nil
}
_ = json.Unmarshal(e.Body, &body)
if body.Machine == "" {
body.Machine = e.Node
if c.ID == "" {
c.ID = e.Headers["x-event-id"]
}
_, _ = h.Chosen(kind, c)
case asks.Link:
l, err := decodeLink(e.Body)
if err != nil {
logf("[messenger] a link asked on %s could not be read", kind)
return nil
}
h.Link(kind, l)
case asks.FailedWhat:
var f asks.Failed
if json.Unmarshal(e.Body, &f) == nil {
h.Failed(kind, f)
}
case asks.StandingWhat:
var s asks.Standing
if json.Unmarshal(e.Body, &s) == nil {
h.Bench.Stand(kind, Standing{Ready: s.Ready, Why: s.Why, EditsSilently: s.EditsSilently, At: s.At})
}
h.Reoffer(body.Machine)
return nil
}
if e.Key != AnswerEvent {
return nil
}
var body struct {
Token string `json:"token"`
Machine string `json:"machine"`
}
if err := json.Unmarshal(e.Body, &body); err != nil || body.Token == "" {
logf("[messenger] an answer from the desk could not be read: %s", firstLineOf(string(e.Body)))
return nil
}
machine := body.Machine
if machine == "" {
machine = e.Node
}
// The machine is the event's own node, said by the runtime: the body's word is only a hint.
if e.Node != "" {
machine = e.Node
}
_, _ = h.Chosen("desktop", machine, body.Token, e.Headers["x-event-id"])
return nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.Subscribe("dunst.*", handle); err == nil {
logf("[messenger] listening for the operator's answers on the desk")
return
if err := stdio.Subscribe(asks.IntakeSeat+".#", handle); err == nil {
logf("[messenger] listening for what the channels say")
break
} else {
logf("[messenger] not hearing the desk's answers yet (%v); asking again in %s", err, wait)
logf("[messenger] not hearing the channels yet (%v); asking again in %s", err, wait)
}
time.Sleep(wait)
}
}
// takeAsks takes the asks and cancels submitted to the seat, each under its asker's name.
func takeAsks(h *Holder) {
handle := func(w stdio.Work) error {
parts := strings.Split(w.Subject, ".")
if len(parts) != 6 {
logf("[messenger] work on %s is not an ask; taken and dropped", w.Subject)
return nil
}
return h.TakeAsk(parts[4], parts[5], w.Body)
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.SeatTake(AsksWorker, handle); err == nil {
logf("[messenger] taking the asks submitted to %s", asks.Seat)
break
} else {
logf("[messenger] not taking asks yet (%v); asking again in %s", err, wait)
}
time.Sleep(wait)
}
}
// AsksWorker is the worker the seat's asks are taken from, as the controller names it.
const AsksWorker = "SEAT_OPERATOR_CHANNEL_worker"
// answerProofs answers the codes the channels ask as proofs: never an event, never kept.
func answerProofs(h *Holder) {
handle := func(subject string, body json.RawMessage) (any, error) {
parts := strings.Split(subject, ".")
kind := parts[len(parts)-1]
var c asks.Code
if err := json.Unmarshal(body, &c); err != nil {
return asks.ProofAnswer{Words: "The code could not be read."}, nil
}
return h.Prove(kind, c), nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.SeatAnswer(asks.ProofSubject(asks.CodeProof, "*"), handle); err == nil {
logf("[messenger] answering the codes the channels are sent")
break
} else {
logf("[messenger] not answering codes yet (%v); asking again in %s", err, wait)
}
time.Sleep(wait)
}
}
// followKinds reads, every half minute, which channel kinds the controller says exist and what each
// promises: never from a channel.
func followKinds(h *Holder) {
said := ""
for {
kinds, err := stdio.SeatKinds()
if err != nil {
if err.Error() != said {
logf("[messenger] which channels exist cannot be read (%v); asking again", err)
said = err.Error()
}
} else {
var out []KindInfo
for _, k := range kinds {
if k.Seat == asks.ChannelSeat {
out = append(out, KindInfo{Kind: k.Kind, Module: k.Module, Node: k.Node, Capabilities: k.Capabilities})
}
}
h.Bench.SetKinds(out)
said = ""
}
time.Sleep(30 * time.Second)
}
}
// syncFromController reads the controller's open conditions and gives them to the holder.
func syncFromController(h *Holder) {
asked := h.Now()
@@ -398,34 +538,40 @@ func tools(h *Holder, l *listening) []stdio.Tool {
strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved"))
}},
{Name: "messenger_status",
Description: "Whether the operator can be reached, and why not: each channel — can it send, what it lacks " +
"(the Telegram bot token and chat id, the desktop machines), its last error, how many it sent in the " +
"last hour against the cap, how many it holds — the open and silenced count, messages not sent yet, " +
"refusals, unreadable events, whether condition events arrive, and the rules it applies. check asks " +
"Telegram who the bot is, sending nothing.",
Input: map[string]any{"check": map[string]any{"type": "boolean", "description": "also ask Telegram whether the token works"}},
Description: "Whether the operator can be reached, and why not: each channel kind the mesh holds — can it " +
"send, what it said of itself, its last error, how many it sent in the last hour against the cap, how " +
"many it holds — the open and silenced count, messages not sent yet, refusals, unreadable events, " +
"whether condition events arrive, and the rules it applies.",
Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }},
{Name: "operator-channel.asks",
Description: "The asks this router holds (novox/hq ADR 0259): open first, then the last month's — each " +
"one's asker, headline, state, when it opened and expires, the channels it was offered on, how it " +
"ended (the warrant: who chose what, through which channel), and whether its asker was told.",
Run: func(map[string]any) (any, error) { return h.AsksNow(), nil }},
{Name: "messenger_identities",
Description: "The accounts linked as the operator, one per channel kind: the account, the name its " +
"service shows, when it was linked, and from when it can approve (an hour after linking).",
Run: func(map[string]any) (any, error) { return h.Identities(), nil }},
{Name: "messenger_unlink",
Description: "Unlink the account linked as the operator on one channel kind: from then its answers are " +
"refused until an account is linked again. Said on every channel, the unlinked one included.",
Input: map[string]any{"kind": str("the channel kind, e.g. telegram")},
Run: func(a map[string]any) (any, error) {
st := h.Status(l.get())
if check, _ := a["check"].(bool); check {
if t, ok := h.Telegram.(*Telegram); ok {
who, err := t.Who()
if err != nil {
return map[string]any{"status": st, "telegram_check": "failed: " + err.Error()}, nil
}
return map[string]any{"status": st, "telegram_check": "the token works: the bot is @" + who}, nil
}
kind := strArg(a, "kind")
if kind == "" {
return nil, errorf("kind is required")
}
return st, nil
return h.Unlink(kind)
}},
{Name: "messenger_recent",
Description: "The recent sends — each message, digest, edit and failure on each channel, newest first — and the refusals.",
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many (default 20)"}},
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 20)), nil }},
{Name: "messenger_test",
Description: "Send a test message now, to telegram, desktop or both (default both), through the content rule " +
"and the cap: proves a channel reaches the operator. Answers per channel: sent, or why not.",
Description: "Send a test message now, to one channel kind or every one (default), through the content rule " +
"and the cap: proves a channel reaches the operator. Answers per channel: put on its queue, or why not.",
Input: map[string]any{
"channel": map[string]any{"type": "string", "enum": []string{"telegram", "desktop", "both"}},
"channel": str("a channel kind, e.g. telegram, or empty for every one"),
"text": str("the words (default: a line saying it is a test)"),
},
Run: func(a map[string]any) (any, error) { return h.Test(strArg(a, "channel"), strArg(a, "text")), nil }},
@@ -8,20 +8,24 @@ import (
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// The manifest says what the code does: the seat it declares and holds and the verbs it serves, the
// events it consumes (the controller's three), the one it emits, the tool it calls, its state, its
// secret, and its own tools — and names nothing of one installation.
// The manifest says what the code does (novox/hq ADR 0259): the seats it declares — the operator's, and
// the two kinded benches its channels hold — the one it holds and the verbs it serves, the bench it sends
// on, the events it consumes, the one it emits, the tool it calls, its state, and its own tools. It holds
// no channel's secret, and names nothing of one installation.
type manifest struct {
Module string `json:"module"`
Seats []seat `json:"seats"`
Claims []seat `json:"claims"`
Uses []string
Consumes []string
Emits []string
Invokes []string
State []string
State []json.RawMessage
Own map[string]string `json:"own-secrets"`
Tools []string
Build struct {
@@ -30,9 +34,15 @@ type manifest struct {
}
type seat struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
Accepts []string `json:"accepts"`
Emits []string `json:"emits"`
Kinded bool `json:"kinded"`
ByCaller []string `json:"by-caller"`
Proofs []string `json:"proofs"`
Records []string `json:"records"`
}
func readManifest(t *testing.T) (manifest, string) {
@@ -48,29 +58,49 @@ func readManifest(t *testing.T) (manifest, string) {
return m, string(raw)
}
func TestItDeclaresAndHoldsTheOperatorChannel(t *testing.T) {
func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) {
m, _ := readManifest(t)
want := seat{Name: "operator-channel", Scope: "mesh", Serves: []string{"open", "history", "notify"}}
if len(m.Seats) != 1 || !reflect.DeepEqual(m.Seats[0], want) || len(m.Claims) != 1 || !reflect.DeepEqual(m.Claims[0], want) {
t.Fatalf("seats %+v claims %+v", m.Seats, m.Claims)
verbs := []string{"open", "history", "notify", "asks"}
want := []seat{
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}},
{Name: asks.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{asks.Show, asks.Edit, asks.Send}},
{Name: asks.IntakeSeat, Scope: "mesh", Kinded: true,
Emits: []string{asks.Chosen, asks.Link, asks.FailedWhat, asks.StandingWhat}, Proofs: []string{asks.CodeProof}},
}
if !reflect.DeepEqual(m.Seats, want) {
t.Fatalf("seats %+v", m.Seats)
}
if len(m.Claims) != 1 || m.Claims[0].Name != asks.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) {
t.Fatalf("claims %+v", m.Claims)
}
if !reflect.DeepEqual(m.Uses, []string{asks.ChannelSeat}) {
t.Fatalf("uses %v", m.Uses)
}
if !reflect.DeepEqual(m.Consumes, []string{
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared,
AnswerEvent, StartedEvent}) {
"intake.choice", "intake.link", "intake.failed", "intake.standing"}) {
t.Fatalf("consumes %v", m.Consumes)
}
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions"}) {
// It performs nothing: the one verb it calls reads the open conditions.
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions"}) {
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
}
if !reflect.DeepEqual(m.State, []string{"open", "sent"}) {
t.Fatalf("state %v", m.State)
var state []string
for _, s := range m.State {
var name string
if json.Unmarshal(s, &name) != nil {
var full struct{ Name string }
_ = json.Unmarshal(s, &full)
name = full.Name
}
state = append(state, name)
}
if m.Own["telegram-token"] == "" {
t.Fatalf("own secrets %v", m.Own)
if !reflect.DeepEqual(state, []string{"open", "sent", "asks", "identities"}) {
t.Fatalf("state %v", state)
}
env, _ := m.Build.Artifacts[0]["env"].(map[string]any)
if env["MESH_MESSENGER_TELEGRAM_TOKEN_FILE"] != m.Own["telegram-token"] {
t.Fatalf("the bundle reads its token from %v, the secret is at %v", env["MESH_MESSENGER_TELEGRAM_TOKEN_FILE"], m.Own["telegram-token"])
if len(m.Own) != 0 {
t.Fatalf("it holds a channel's secret: %v", m.Own)
}
}
@@ -99,7 +129,7 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
if !reflect.DeepEqual(verbs, m.Claims[0].Serves) {
t.Fatalf("verbs %v, claim %v", verbs, m.Claims[0].Serves)
}
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox", "zurag", "api.telegram"} {
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox", "zurag", "telegram"} {
if strings.Contains(strings.ToLower(raw), never) {
t.Errorf("module.json names %q", never)
}
+21 -14
View File
@@ -33,7 +33,15 @@ func (h *Holder) deliver(r *Record, what string) {
return
}
var to []Channel
if r.Severity == Urgent {
if kinds := h.askKinds(r); len(kinds) > 0 {
// An ask goes to every channel that carries one of its answers (novox/hq ADR 0259): the desk for an
// acknowledgement, the channel that proves its sender for an approval — whatever its severity.
for _, ch := range h.channels() {
if kinds[ch.Name()] && ch.Ready() == nil {
to = append(to, ch)
}
}
} else if r.Severity == Urgent {
for _, ch := range h.channels() {
if ch.Ready() == nil {
to = append(to, ch)
@@ -41,10 +49,10 @@ func (h *Holder) deliver(r *Record, what string) {
}
} else {
switch {
case h.Desktop != nil && h.Desktop.Ready() == nil:
to = []Channel{h.Desktop}
case h.Telegram != nil && h.Telegram.Ready() == nil:
to = []Channel{h.Telegram}
case h.Desk != nil && h.Desk.Ready() == nil:
to = []Channel{h.Desk}
case h.Away != nil && h.Away.Ready() == nil:
to = []Channel{h.Away}
}
}
if len(to) == 0 {
@@ -113,7 +121,6 @@ func (h *Holder) queued(key string) bool {
// edit fails, or that said it inside a digest, is given a new message instead — held like any other.
// silentOnly: only where an edit notifies nobody; elsewhere nothing.
func (h *Holder) edit(r *Record, what string, silentOnly bool) {
m := h.say(r, what)
h.mu.Lock()
sent := map[string]string{}
for k, v := range r.Sent {
@@ -131,7 +138,7 @@ func (h *Holder) edit(r *Record, what string, silentOnly bool) {
continue
}
if id != "" && ch.CanEdit() {
err := ch.Edit(id, m)
err := ch.Edit(id, h.sayOn(r, what, ch.Name()))
h.noteChannel(ch.Name(), err)
if err == nil {
h.record(Sent{At: now, Channel: ch.Name(), Key: r.Key, What: what, Outcome: "edited"})
@@ -204,14 +211,14 @@ func (h *Holder) due(ch Channel, list []item, now time.Time) bool {
if now.Sub(oldest) < BurstWindow {
return false
}
if ch == h.Desktop && !lastWarn.IsZero() && now.Sub(lastWarn) < DesktopWarningEvery {
if ch == h.Desk && !lastWarn.IsZero() && now.Sub(lastWarn) < DesktopWarningEvery {
return false
}
return true
}
// line is what an item says, resolved against the record now: nil when it is no longer to be said.
func (h *Holder) resolve(it item, now time.Time) (*Record, *Message) {
func (h *Holder) resolve(it item, now time.Time, kind string) (*Record, *Message) {
if it.Msg != nil {
return nil, it.Msg
}
@@ -224,7 +231,7 @@ func (h *Holder) resolve(it item, now time.Time) (*Record, *Message) {
if (it.What == "cleared") != !r.Cleared.IsZero() {
return nil, nil
}
m := h.say(r, it.What)
m := h.sayOn(r, it.What, kind)
return r, &m
}
@@ -238,7 +245,7 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
}
var entries []entry
for _, it := range list {
r, m := h.resolve(it, now)
r, m := h.resolve(it, now, name)
if m == nil {
continue
}
@@ -341,7 +348,7 @@ func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool {
h.lastSend[name] = now
h.lastTry[name] = time.Time{}
for _, e := range entries {
if ch == h.Desktop && !e.it.urgent() {
if ch == h.Desk && !e.it.urgent() {
// The desktop's warning rhythm counts from a message that carried a warning.
h.lastWarn[name] = now
}
@@ -404,14 +411,14 @@ func (h *Holder) failed(ch Channel, list []item, taken int, now time.Time, err e
h.lastTry[name] = now
h.mu.Unlock()
h.record(Sent{At: now, Channel: name, Key: "(held)", What: fmt.Sprintf("%d held", taken), Outcome: "failed: " + err.Error()})
if ch == h.Desktop && h.Telegram != nil && h.Telegram.Ready() == nil {
if ch == h.Desk && h.Away != nil && h.Away.Ready() == nil {
var keep []item
for _, it := range list[:taken] {
if it.Severity == Urgent {
keep = append(keep, it)
continue
}
h.enqueue(h.Telegram.Name(), it)
h.enqueue(h.Away.Name(), it)
}
h.mu.Lock()
h.outbox[name] = append(keep, h.outbox[name][taken:]...)
@@ -0,0 +1,304 @@
package main
// The channels, as the router sees them (novox/hq ADR 0259): kinds of the `channel` bench, each held by a
// module of its own. The router keeps no channel's client and knows no service. What it knows of a kind
// comes from two places, never from one alone:
//
// - **the controller**, in this module's membership: which kinds exist, which module holds each, and the
// capabilities its claim declares (`channel-capabilities/1`). An answer is judged by these.
// - **the channel**, on the `intake` bench under its own kind: whether it can send now (`standing`), and
// a message it could not deliver (`failed`).
//
// A message is sent by putting it on the kind's work queue (`mesh.seat.channel.accept.<verb>.<kind>`) under
// a handle the router makes; the channel keeps which of its own messages that handle is, so an edit names
// the handle. A queued message survives the channel being away.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// StandingFresh is how long a channel's word on whether it can send is believed: a channel says it at
// least every five minutes, so a word older than this is a channel that stopped saying.
const StandingFresh = 15 * time.Minute
// KindInfo is one kind of the channel bench as the controller issued it.
type KindInfo struct {
Kind string
Module string
Node string
Capabilities []string
}
// Standing is what a channel last said of itself.
type Standing struct {
Ready bool `json:"ready"`
Why string `json:"why,omitempty"`
EditsSilently bool `json:"edits-silently,omitempty"`
At time.Time `json:"at"`
}
// Bench is every kind the router may send to, and what each said.
type Bench struct {
Now func() time.Time
// To is the account linked as the operator on a kind, where it verifies its sender: where its messages go.
To func(kind string) string
mu sync.Mutex
kinds map[string]KindInfo
standing map[string]Standing
known bool
}
// NewBench is a bench that knows no kind yet.
func NewBench(now func() time.Time) *Bench {
return &Bench{Now: now, kinds: map[string]KindInfo{}, standing: map[string]Standing{}}
}
// SetKinds takes the kinds the controller issued.
func (b *Bench) SetKinds(kinds []KindInfo) {
b.mu.Lock()
defer b.mu.Unlock()
b.kinds = map[string]KindInfo{}
for _, k := range kinds {
b.kinds[k.Kind] = k
}
b.known = true
}
// Stand takes what a kind said of itself.
func (b *Bench) Stand(kind string, s Standing) {
b.mu.Lock()
defer b.mu.Unlock()
b.standing[kind] = s
}
// Kind is one kind, as the controller issued it.
func (b *Bench) Kind(kind string) (KindInfo, bool) {
b.mu.Lock()
defer b.mu.Unlock()
k, ok := b.kinds[kind]
return k, ok
}
// Kinds is every kind, by name.
func (b *Bench) Kinds() []KindInfo {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]KindInfo, 0, len(b.kinds))
for _, k := range b.kinds {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool { return out[i].Kind < out[j].Kind })
return out
}
// Promises says whether a kind's claim declares a capability: the controller's record, never the channel's.
func (b *Bench) Promises(kind, capability string) bool {
k, ok := b.Kind(kind)
if !ok {
return false
}
for _, c := range k.Capabilities {
if c == capability {
return true
}
}
return false
}
// Ready says whether a kind can send now, in words when it cannot.
func (b *Bench) Ready(kind string) error {
b.mu.Lock()
_, held := b.kinds[kind]
s, said := b.standing[kind]
known := b.known
b.mu.Unlock()
switch {
case !held && !known:
return errors.New("the mesh has not yet said which channels exist")
case !held:
return fmt.Errorf("no module holds the channel %s", kind)
case !said:
return fmt.Errorf("%s has not said whether it can send", kind)
case b.Now().Sub(s.At) > StandingFresh:
return fmt.Errorf("%s has said nothing since %s", kind, s.At.UTC().Format(time.RFC3339))
case !s.Ready:
if s.Why == "" {
return fmt.Errorf("%s says it cannot send", kind)
}
return errors.New(s.Why)
}
return nil
}
func (b *Bench) editsSilently(kind string) bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.standing[kind].EditsSilently
}
// Roles: the away channel reaches the operator away from the machines; the desk is at them.
const (
RoleAway = "away"
RoleDesk = "desk"
)
// roleOf is a kind's role, from what its claim declares.
func (b *Bench) roleOf(kind string) string {
if b.Promises(kind, "reaches-away") {
return RoleAway
}
return RoleDesk
}
// Display is a kind as the operator reads it: "Telegram", "the desk".
func Display(kind string) string {
if kind == "desktop" {
return "the desk"
}
if kind == "" {
return kind
}
return strings.ToUpper(kind[:1]) + kind[1:]
}
// SeatChannel is one role's channel: the first kind of that role that can send, else the first of it.
type SeatChannel struct {
Bench *Bench
Role string
Publish func(subject string, body any, id string) error
}
func (c *SeatChannel) kind() string {
var first string
for _, k := range c.Bench.Kinds() {
if c.Bench.roleOf(k.Kind) != c.Role {
continue
}
if first == "" {
first = k.Kind
}
if c.Bench.Ready(k.Kind) == nil {
return k.Kind
}
}
return first
}
// Name is the kind it sends to now, or its role when no kind holds it.
func (c *SeatChannel) Name() string {
if k := c.kind(); k != "" {
return k
}
return c.Role
}
func (c *SeatChannel) Ready() error {
k := c.kind()
if k == "" {
if c.Role == RoleAway {
return errors.New("no channel reaching the operator away from the machines is held: a module claims a " +
"kind of the channel bench that promises reaches-away (novox/hq ADR 0259)")
}
return errors.New("no channel at the operator's machines is held")
}
return c.Bench.Ready(k)
}
func (c *SeatChannel) CanEdit() bool { return c.Bench.Promises(c.kind(), "edit") }
func (c *SeatChannel) SilentEdit() bool { return c.Bench.editsSilently(c.kind()) }
// Send puts a message on the kind's queue and answers the handle it is known by.
func (c *SeatChannel) Send(m Message) (string, error) {
handle := newHandle()
return handle, c.put(handle, m, false)
}
// Edit replaces the message a handle names.
func (c *SeatChannel) Edit(handle string, m Message) error {
if handle == "" {
return errors.New("no message on record to replace")
}
return c.put(handle, m, true)
}
func (c *SeatChannel) put(handle string, m Message, edit bool) error {
kind := c.kind()
if kind == "" {
return c.Ready()
}
return c.putOn(kind, handle, m, edit)
}
func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
if c.Publish == nil {
return errors.New("this router cannot reach the bus")
}
verb := asks.Send
switch {
case edit:
verb = asks.Edit
case len(m.Actions) > 0:
verb = asks.Show
}
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
if c.Bench.To != nil {
out.To = c.Bench.To(kind)
}
for _, a := range m.Actions {
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
}
return c.Publish(asks.ChannelSubject(verb, kind), out, handle+"."+fmt.Sprint(time.Now().UnixNano()))
}
func newHandle() string {
var b [9]byte
_, _ = rand.Read(b[:])
return "m" + hex.EncodeToString(b[:])
}
// Asker calls a tool through the mesh, as stdio.Ask does: how the open conditions are read.
type Asker func(key string, body any) (json.RawMessage, error)
// KindChannel is one kind as a channel: for a test, and for the status of each.
type KindChannel struct {
Bench *Bench
Kind string
Publish func(subject string, body any, id string) error
}
func (c *KindChannel) Name() string { return c.Kind }
func (c *KindChannel) Ready() error { return c.Bench.Ready(c.Kind) }
func (c *KindChannel) CanEdit() bool { return c.Bench.Promises(c.Kind, "edit") }
func (c *KindChannel) SilentEdit() bool { return c.Bench.editsSilently(c.Kind) }
func (c *KindChannel) Send(m Message) (string, error) {
s := &SeatChannel{Bench: c.Bench, Publish: c.Publish}
handle := newHandle()
return handle, s.putOn(c.Kind, handle, m, false)
}
func (c *KindChannel) Edit(handle string, m Message) error {
s := &SeatChannel{Bench: c.Bench, Publish: c.Publish}
return s.putOn(c.Kind, handle, m, true)
}
// kindChannels is every kind the controller says exists, as a channel; the two roles when it says none yet.
func (h *Holder) kindChannels() []Channel {
if h.Bench == nil || len(h.Bench.Kinds()) == 0 {
return h.channels()
}
var out []Channel
for _, k := range h.Bench.Kinds() {
out = append(out, &KindChannel{Bench: h.Bench, Kind: k.Kind, Publish: h.Publish})
}
return out
}
+9 -5
View File
@@ -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
}
-206
View File
@@ -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\": \"<the chat's 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 <machine> 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 <machine> 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, "<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
}
+9 -19
View File
@@ -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")
}
}