diff --git a/modules/messenger/README.md b/modules/messenger/README.md index cc76a080..722c9cbe 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -183,6 +183,7 @@ ends takes them away everywhere; its expiry is said with its day when it is not | `messenger_status` | whether the operator can be reached and why not, per channel kind | | `messenger_recent` | the recent sends, edits, digests, answers and failures | | `messenger_test` | a test message now, to one kind or every one | +| `messenger_preview` | what a condition's message would say, tracking line and all, without sending it | | `messenger_check` | whether some words may leave the mesh | | `messenger_identities` | the accounts linked as the operator, and from when each approves | | `messenger_unlink` | unlink a kind's account, said on every channel | diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index c20b5041..a4711ccb 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -603,6 +603,15 @@ func tools(h *Holder, l *listening) []stdio.Tool { "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 }}, + {Name: "messenger_preview", + Description: "What a condition's message would say, without sending it: the title, the words, the " + + "line on the issues that track it, and whether the content rule would let them leave the mesh. " + + "Composed exactly as it would be sent, from a condition this router holds open; sends nothing.", + Input: map[string]any{ + "key": str("the condition's key, as the controller names it, e.g. module.openrazer.laptop.relogin-needed"), + "what": str("which message about it: raised (default), reminder, quiet-reminder, escalated, reopened, cleared, silenced"), + }, + Run: func(a map[string]any) (any, error) { return h.Preview(strArg(a, "key"), strArg(a, "what")) }}, {Name: "messenger_check", Description: "Would these words be allowed to leave the mesh? Runs the content rule — no address, path or " + "secret — and answers allowed, or the class of what it carried. Sends nothing.", diff --git a/modules/messenger/cmd/messenger/preview.go b/modules/messenger/cmd/messenger/preview.go new file mode 100644 index 00000000..cf483bda --- /dev/null +++ b/modules/messenger/cmd/messenger/preview.go @@ -0,0 +1,113 @@ +package main + +// What a condition's message would say, read before it is sent (novox/hq issue 422). The words a +// condition reaches the operator with are composed here, in this router, from the condition's own plain +// words and the line on the issues that track it (tracking.go). Until this tool, the only way to read +// them was to wait for a send and look at the operator's screen: the channels keep the text of nothing +// they sent (`Sent` holds a key and an outcome, never words), and a notification still on the desk is +// not in the notifier's history. So a wording could not be proven without nagging the operator. +// +// It composes exactly what `composeTracked` composes for a send — the same words, the same tracking +// line, the same verdict — and sends nothing, queues nothing and records nothing. It also says whether +// the content rule would let those words leave the mesh, which is the other half of what a send decides. +// +// It reads the conditions this router holds open. A key it does not hold is refused by name: a +// condition that is not open has no message, and one invented here would prove nothing about the real +// one. + +import ( + "sort" + "strings" + "time" +) + +// The messages a condition is said with, as `what` names them through the sending path (words.go). +// `raised` is the first message about it; `cleared` and `silenced` carry no tracking line (ADR 0285 §3). +var previewable = []string{"raised", "reminder", "quiet-reminder", "escalated", "reopened", "cleared", "silenced"} + +// Preview is the message for a condition this router holds, composed as it would be sent, and sent +// nowhere. `what` is which of the messages about it (previewable, default `raised`). +func (h *Holder) Preview(key, what string) (map[string]any, error) { + key = strings.TrimSpace(key) + if key == "" { + return nil, errorf("key is required: the condition's key, as the controller names it — `mesh-controller.conditions` says which are open") + } + what = strings.TrimSpace(what) + if what == "" { + what = "raised" + } + if !previewableWhat(what) { + return nil, errorf("what %q is not one of the messages a condition is said with: %s", what, strings.Join(previewable, ", ")) + } + + // Asked outside the work, as the router asks it on its own tick: a preview on a cold tracking word + // would say the tracking could not be checked, which is true of this moment and not of a send. + h.warmTracking() + + h.mu.Lock() + held, ok := h.open[key] + var r Record + if ok { + r = *held // composed from a copy: a preview changes nothing this router holds + } + open := make([]string, 0, len(h.open)) + for k := range h.open { + open = append(open, k) + } + h.mu.Unlock() + if !ok { + sort.Strings(open) + if len(open) == 0 { + return nil, errorf("no condition %s is open here, and this router holds none: `mesh-controller.conditions` says what the controller holds open", key) + } + return nil, errorf("no condition %s is open here; it holds %s", key, strings.Join(open, ", ")) + } + + now := h.Now() + m := h.composeTracked(&r, what, now, 0) + out := map[string]any{ + "key": key, + "what": what, + "title": m.Title, + "body": m.Body, + "sent": "nothing: this composes the words and sends nowhere", + } + if m.Urgent { + out["urgent"] = true + } + if m.Quiet { + out["quiet"] = true + } + if line, said := h.trackingLine(&r, what, now); said { + out["tracking"] = line + } + if refusal, allowed := Check(m.Text()); allowed { + out["allowed"] = true + } else { + // A send would say less instead (compose with a withhold level), never these words. + out["allowed"] = false + out["carried"] = refusal.What + out["class"] = refusal.Class + } + return out, nil +} + +// trackingLine is the line on the issues that track the condition, and whether this message carries one: +// a clearing and a silence say none, and nor does a router that cannot ask the tracker. +func (h *Holder) trackingLine(r *Record, what string, now time.Time) (string, bool) { + if !h.tracksAsked() || r.Origin != "condition" || what == "cleared" || what == "silenced" { + return "", false + } + issues, known := h.trackedBy(r.Key) + line, _ := trackingWords(issues, known, now, h.zone()) + return line, line != "" +} + +func previewableWhat(what string) bool { + for _, w := range previewable { + if w == what { + return true + } + } + return false +} diff --git a/modules/messenger/cmd/messenger/preview_test.go b/modules/messenger/cmd/messenger/preview_test.go new file mode 100644 index 00000000..fc9ec02b --- /dev/null +++ b/modules/messenger/cmd/messenger/preview_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// A condition's message can be read before it is sent (novox/hq issue 422): the same words a send would +// carry, the tracking line among them, and nothing goes out while they are read. + +// previewHolder is a router holding one raised condition that needs nothing, with the issues given +// tracking its key. +func previewHolder(t *testing.T, issues []TrackedIssue) (*Holder, *fakeChannel, *fakeChannel, *clock, Condition) { + t.Helper() + h, tg, dt, c, _ := newHolder(t) + h.Tracking = func() (map[string][]TrackedIssue, error) { + return map[string][]TrackedIssue{"mesh.hand-acts.rolled-back.healer-wanted": issues}, nil + } + k := cond("mesh.hand-acts.rolled-back.healer-wanted", Warning, "rolled-back was repaired by hand 4 times in 14 days") + k.Explanation = "Nothing for you to do. A person repaired rolled-back by hand 4 times in 14 days, so an automatic repair is wanted for it. Nothing is broken now." + k.Needs = "" + k.Raised = c.now() + h.Condition(EventRaised, k) + settle(h, c) + tg.sends, dt.sends = nil, nil + return h, tg, dt, c, k +} + +func TestAConditionsMessageIsReadWithoutBeingSent(t *testing.T) { + h, tg, dt, _, _ := previewHolder(t, []TrackedIssue{{Number: 352, Status: "located", Needs: "decision"}}) + + out, err := h.Preview("mesh.hand-acts.rolled-back.healer-wanted", "") + if err != nil { + t.Fatal(err) + } + if out["what"] != "raised" { + t.Errorf("what %v; a preview with none asked is the first message", out["what"]) + } + if out["title"] != "mesh hand-acts rolled-back healer-wanted" { + t.Errorf("title %q", out["title"]) + } + body, _ := out["body"].(string) + if !strings.Contains(body, "Tracked as issue 352: nobody is working on it; it waits for a decision first.") { + t.Errorf("the body says nothing of the issue that tracks it: %q", body) + } + if out["tracking"] != "Tracked as issue 352: nobody is working on it; it waits for a decision first." { + t.Errorf("tracking %q", out["tracking"]) + } + if out["allowed"] != true { + t.Errorf("allowed %v, carried %v: these words may leave the mesh", out["allowed"], out["carried"]) + } + // The verdict is left out while nobody works on the issue (ADR 0285 §4), as in a send. + if strings.Contains(body, "Nothing for you to do.") { + t.Errorf("the preview reassures where nothing is being done: %q", body) + } + if len(tg.sends) != 0 || len(dt.sends) != 0 { + t.Fatalf("a preview sent something: telegram %d, desktop %d", len(tg.sends), len(dt.sends)) + } + keyless(t, Message{Title: out["title"].(string), Body: body}) +} + +func TestAPreviewSaysTheSameWordsTheSendWouldCarry(t *testing.T) { + issues := []TrackedIssue{{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(21, 10)}} + h, tg, dt, c, k := previewHolder(t, issues) + + out, err := h.Preview(k.Key, "reminder") + if err != nil { + t.Fatal(err) + } + // The same record, the same moment, through the sending path. + h.mu.Lock() + r := *h.open[k.Key] + h.mu.Unlock() + want := h.composeTracked(&r, "reminder", c.now(), 0) + if out["title"] != want.Title || out["body"] != want.Body { + t.Fatalf("preview %q / %q; a send would say %q / %q", out["title"], out["body"], want.Title, want.Body) + } + if !strings.Contains(want.Body, "Tracked as issue 386: fix-386 on g14 is fixing it since ") { + t.Fatalf("the message this is compared against carries no tracking line: %q", want.Body) + } + if len(tg.sends) != 0 || len(dt.sends) != 0 { + t.Fatalf("a preview sent something: telegram %d, desktop %d", len(tg.sends), len(dt.sends)) + } +} + +func TestAClearingAndASilencePreviewWithoutATrackingLine(t *testing.T) { + h, _, _, _, k := previewHolder(t, []TrackedIssue{{Number: 352, Status: "located"}}) + for _, what := range []string{"cleared", "silenced"} { + out, err := h.Preview(k.Key, what) + if err != nil { + t.Fatal(err) + } + if _, said := out["tracking"]; said { + t.Errorf("%s says a tracking line: %v", what, out["tracking"]) + } + if body, _ := out["body"].(string); strings.Contains(body, "issue") { + t.Errorf("%s names an issue: %q", what, body) + } + } +} + +func TestAPreviewOfAConditionTheRouterDoesNotHoldIsRefusedByName(t *testing.T) { + h, _, _, _, k := previewHolder(t, nil) + + _, err := h.Preview("machine.ace.silent", "") + if err == nil { + t.Fatal("a condition this router does not hold was previewed") + } + if !strings.Contains(err.Error(), "machine.ace.silent") || !strings.Contains(err.Error(), k.Key) { + t.Errorf("the refusal says neither what was asked nor what is held: %v", err) + } + + if _, err := h.Preview("", ""); err == nil || !strings.Contains(err.Error(), "key is required") { + t.Errorf("no key: %v", err) + } + if _, err := h.Preview(k.Key, "shouted"); err == nil || !strings.Contains(err.Error(), "shouted") { + t.Errorf("an unknown message: %v", err) + } +} + +func TestAPreviewAsksTheTrackerWhenItsWordWentCold(t *testing.T) { + asked := 0 + h, _, _, c, k := previewHolder(t, []TrackedIssue{{Number: 352, Status: "located"}}) + h.Tracking = func() (map[string][]TrackedIssue, error) { + asked++ + return map[string][]TrackedIssue{k.Key: {{Number: 352, Status: "located"}}}, nil + } + + c.pass(TrackingKept + time.Minute) // older than an answer is used for + out, err := h.Preview(k.Key, "quiet-reminder") + if err != nil { + t.Fatal(err) + } + if asked != 1 { + t.Fatalf("the tracker was asked %d times; a preview on a cold word asks once", asked) + } + if out["tracking"] != "Tracked as issue 352: nobody is working on it." { + t.Errorf("tracking %q; a preview that asked should say what it heard", out["tracking"]) + } +} + +func TestAPreviewSaysWhenTheWordsWouldNotBeAllowedOut(t *testing.T) { + h, _, _, c, _ := previewHolder(t, nil) + k := cond("machine.ace.silent", Warning, "the home server has not been heard for 15 min") + k.Explanation = "Needs you: look at /home/someone/secrets.txt on it." + k.Raised = c.now() + h.Condition(EventRaised, k) + + out, err := h.Preview(k.Key, "") + if err != nil { + t.Fatal(err) + } + if out["allowed"] != false || out["carried"] == nil { + t.Fatalf("allowed %v, carried %v; the content rule refuses a path", out["allowed"], out["carried"]) + } +} diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 1c426333..b1fde436 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -103,6 +103,7 @@ "messenger_status", "messenger_recent", "messenger_test", + "messenger_preview", "messenger_check", "messenger_identities", "messenger_unlink"