diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index bc8dfc34..70116c23 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -670,6 +670,7 @@ func (h *Holder) showAsk(key string, rec AskRecord, now time.Time) { target := h.open[rec.Record] h.mu.Unlock() h.Logf("[messenger] %s asks %q (%s), shown on %s", asker, a.Headline, a.ID, rec.Record) + h.sayOffered(key, rec) if target != nil { h.mu.Lock() target.Ask = key @@ -917,6 +918,7 @@ func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *ask rev = newRev } h.keepAsk(key, rec, rev) + h.sayClosed(key, rec, outcome, words) if rec.Asker == SelfAsker { // The router's own ask: it does what was decided, and tells nobody else (selfact.go). h.markTold(key) @@ -1416,3 +1418,55 @@ func (h *Holder) AsksNow() []AskView { }) return out } + +// sayOffered says that an ask was offered, once: its id, asker, headline and the kinds of channel it was offered on; never an +// option, a ticket or a warrant's proof, so it says no more than `asks` already answers (novox/hq ADR 0616 §7). A held ask is +// said when it is released and shown, not before. +func (h *Holder) sayOffered(key string, rec AskRecord) { + if h.Emit == nil || rec.State != StateOpen { + return + } + h.mu.Lock() + if h.offeredSaid == nil { + h.offeredSaid = map[string]bool{} + } + done := h.offeredSaid[key] + h.offeredSaid[key] = true + h.mu.Unlock() + if done { + return + } + kinds := map[string]bool{} + for _, t := range rec.Tickets { + kinds[t.Kind] = true + } + var on []string + for k := range kinds { + on = append(on, k) + } + sort.Strings(on) + if err := h.Emit("offered", map[string]any{"key": key, "asker": rec.Asker, "headline": rec.Ask.Headline, "offered_on": on, "at": h.Now()}); err != nil { + h.Logf("[messenger] that the ask %s was offered could not be said: %v", key, err) + } +} + +// sayClosed says how an ask ended, once per ask (its end is recorded by compare-and-set, so a second close never gets here): answered, +// expired, cancelled by its asker, replaced by a newer ask, or ended by the router itself on an unlink or allow-link. +func (h *Holder) sayClosed(key string, rec AskRecord, outcome asks.Outcome, words string) { + if h.Emit == nil { + return + } + how := string(outcome) + switch { + case outcome == asks.OutcomeChosen: + how = "answered" + case outcome == asks.OutcomeCancelled && words != "its asker took it back": + how = "ended by the router" + } + h.mu.Lock() + delete(h.offeredSaid, key) + h.mu.Unlock() + if err := h.Emit("closed", map[string]any{"key": key, "asker": rec.Asker, "headline": rec.Ask.Headline, "how": how, "at": h.Now()}); err != nil { + h.Logf("[messenger] that the ask %s ended could not be said: %v", key, err) + } +} diff --git a/modules/messenger/cmd/messenger/asks_events_test.go b/modules/messenger/cmd/messenger/asks_events_test.go new file mode 100644 index 00000000..c3f9ca37 --- /dev/null +++ b/modules/messenger/cmd/messenger/asks_events_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// The router says when an ask starts and ends (ADR 0616 §7): `offered` once when an ask is offered, `closed` once for each of the five ends +// (answered, expired, cancelled by its asker, replaced, ended by the router), with how it ended and never an option, a ticket or a +// proof; a held ask is said when it is released. +func TestTheRouterSaysWhenAnAskIsOfferedAndWhenItEnds(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + type said struct { + event string + body map[string]any + } + var got []said + h.Emit = func(event string, body any) error { + raw, _ := json.Marshal(body) + var m map[string]any + _ = json.Unmarshal(raw, &m) + got = append(got, said{event, m}) + return nil + } + ends := func() (out []string) { + for _, s := range got { + if s.event == "closed" { + out = append(out, s.body["key"].(string)+"="+s.body["how"].(string)) + } + } + return + } + // Answered. + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + if len(got) != 1 || got[0].event != "offered" || got[0].body["key"] != "mesh-delivery.d-1" || got[0].body["asker"] != "mesh-delivery" { + t.Fatalf("%+v", got) + } + release := ticketFor(tg.sends[0], "Release") + if _, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err != nil { + t.Fatal(err) + } + // Replaced, then cancelled by its asker. + a := ackAsk(c, "d-2") + a.About = "delivery.hq" + submit(t, h, "mesh-delivery", a) + b := ackAsk(c, "d-3") + b.About = "delivery.hq" + submit(t, h, "mesh-delivery", b) + if err := h.TakeAsk("cancel", "mesh-delivery", json.RawMessage(`{"id":"d-3"}`)); err != nil { + t.Fatal(err) + } + // Expired. + e := heldAsk(c, "d-4") + e.About = "other" + submit(t, h, "mesh-delivery", e) + settle(h, c) + c.pass(3 * time.Hour) + stand(h, c) + h.Tick() + // Ended by the router itself (an unlink or allow-link ends its own asks the same way). + f := ackAsk(c, "d-5") + f.About = "third" + submit(t, h, "mesh-delivery", f) + if err := h.closeAsk("mesh-delivery.d-5", asks.OutcomeCancelled, "root decided to allow a link", nil, "", nil); err != nil { + t.Fatal(err) + } + want := "mesh-delivery.d-1=answered mesh-delivery.d-2=replaced mesh-delivery.d-3=cancelled mesh-delivery.d-4=expired mesh-delivery.d-5=ended by the router" + if gotEnds := strings.Join(ends(), " "); gotEnds != want { + t.Fatalf("the ends %q, want %q", gotEnds, want) + } + // Once each: a second close loses and says nothing; five asks were offered, once each; no option, ticket or proof is in a body. + _ = h.closeAsk("mesh-delivery.d-1", asks.OutcomeCancelled, "again", nil, "", nil) + offered := 0 + for _, s := range got { + if s.event == "offered" { + offered++ + } + for k := range s.body { + if k == "options" || k == "tickets" || k == "token" || k == "proofs" || k == "warrant" { + t.Errorf("%s carries %s", s.event, k) + } + } + } + if offered != 5 || len(ends()) != 5 { + t.Fatalf("%d offered, %d closed", offered, len(ends())) + } +} diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index b3dff885..39226e86 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -313,6 +313,8 @@ type Holder struct { Logf func(string, ...any) // Emit states a fact as this module (refused); nil states nothing. Emit func(event string, body any) error + // offeredSaid are the asks whose offering was said, so it is said once. + offeredSaid map[string]bool // Ask calls a seat verb through the mesh, as this module: the answers the operator chooses. Ask Asker // RootFree asks each machine's engine's root-free (the node seat node-engine's, ADR 0563 §6); nil asks it through Ask diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 20e660c1..1a631605 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -94,7 +94,7 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { // } // It performs nothing: the verbs it calls read the open conditions, whether root is free, and which issues // track which conditions. - if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{ + if !reflect.DeepEqual(m.Emits, []string{"refused", "offered", "closed"}) || !reflect.DeepEqual(m.Invokes, []string{ "seat:mesh-conditions.conditions", "seat:" + EngineSeat + ".root-free", TrackingVerb}) { t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes) } diff --git a/modules/messenger/files/settings.json b/modules/messenger/files/settings.json index 726c29cb..d628295d 100644 --- a/modules/messenger/files/settings.json +++ b/modules/messenger/files/settings.json @@ -2,5 +2,6 @@ "time-zone": "", "only-what-needs-me": [], "messages-per-hour": 100, - "settle-seconds": 120 + "settle-seconds": 120, + "readers": {"offered": ["mesh-knowledge"], "closed": ["mesh-knowledge"]} } diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 40221f1b..f831d641 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -86,7 +86,24 @@ "intake.standing" ], "emits": [ - "refused" + "refused", + "offered", + "closed" + ], + "streams": [ + { + "name": "asks", + "events": [ + "offered", + "closed" + ], + "max-age-days": 7, + "max-mib": 64, + "grant": { + "config": "readers", + "events": "{key}" + } + } ], "invokes": [ "seat:mesh-conditions.conditions", @@ -117,6 +134,12 @@ "messenger_unlink" ], "config": { + "readers": { + "type": "object", + "description": "Who may hear an ask offered or closed (the stream of the asks, kept seven days), per event: offered and closed, each a list of module names, on every machine. The knowledge base alone by default, for its overview page; an asker's own answers (decided) are never on it (ADR 0616 §7).", + "trusted": true, + "default": {"offered": ["mesh-knowledge"], "closed": ["mesh-knowledge"]} + }, "time-zone": { "type": "string", "description": "The time zone every time a message says is said in, an IANA name such as Europe/Brussels; empty is this machine's own zone.", @@ -173,7 +196,7 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", - "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds"], + "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds", "readers"], "merge": "json", "template": "files/settings.json", "owner": "messenger"