issue 797: messenger gains the tell verb for a plain notice
ADR 0380 §8: the operator channel's notify is shaped for conditions (a key that stays open, escalation, a plain rule that rewrites a subject), so a module has no way to say a plain notice such as new mail. tell sends a headline and one or two lines at once to every channel, bypassing routing, the burst window and the walk hold; it offers Details like any message, is kept in the history, but its record is marked cleared at once so it never stays open. Its cap (10 a channel an hour) is its own, apart from notify's and the conditions'.
This commit is contained in:
@@ -86,7 +86,9 @@ func (h *Holder) choices(r *Record, kind string) []Choice {
|
||||
if rec := h.openAsk(r); rec != nil {
|
||||
out = h.offers(rec, kind)
|
||||
}
|
||||
if r.Origin != "condition" || len(out) >= MostActions {
|
||||
// A condition offers Details so the operator can read more; a tell does too (ADR 0380 §8: "Details is
|
||||
// offered as for any message") — it is its own message, so Details just shows it again.
|
||||
if (r.Origin != "condition" && r.Origin != "tell") || len(out) >= MostActions {
|
||||
return out
|
||||
}
|
||||
now := h.Now()
|
||||
|
||||
@@ -345,6 +345,8 @@ type Holder struct {
|
||||
wantSync bool
|
||||
history int
|
||||
stale int
|
||||
// tellSeq makes every tell's key its own, even when two calls land in the same instant.
|
||||
tellSeq 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.
|
||||
|
||||
@@ -524,6 +524,58 @@ func TestNotifyIsKeptApartFromConditions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestATellReachesEveryChannelOnceAndOffersOnlyDetails(t *testing.T) {
|
||||
h, tg, dt, c, _ := newHolder(t)
|
||||
c.pass(14 * time.Hour) // well past midnight: it is not held for being "night" — there is no such hold
|
||||
out, err := h.Tell("new mail arrived for ace", []string{"One message from the inbox you watch."}, "mail")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 {
|
||||
t.Fatalf("telegram %d, desktop %d sends", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
for _, m := range []Message{tg.sends[0], dt.sends[0]} {
|
||||
if len(m.Actions) != 1 || m.Actions[0].Label != DetailsLabel {
|
||||
t.Fatalf("actions %+v", m.Actions)
|
||||
}
|
||||
}
|
||||
if sent, _ := out["sent_on"].([]string); len(sent) != 2 {
|
||||
t.Fatalf("sent_on %v", out)
|
||||
}
|
||||
if len(h.Open()) != 0 {
|
||||
t.Fatalf("a tell left an open record: %+v", h.Open())
|
||||
}
|
||||
c.pass(RemindUrgent + time.Minute)
|
||||
h.Tick()
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 {
|
||||
t.Fatalf("a tell was reminded: telegram %d, desktop %d", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
}
|
||||
|
||||
func TestATellIsDroppedPastItsOwnHourlyCap(t *testing.T) {
|
||||
h, tg, _, _, _ := newHolder(t)
|
||||
h.Desk = nil
|
||||
for i := 0; i < TellCapPerHour; i++ {
|
||||
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(tg.sends) != TellCapPerHour {
|
||||
t.Fatalf("sent %d of %d", len(tg.sends), TellCapPerHour)
|
||||
}
|
||||
out, err := h.Tell("mail", []string{"line"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tg.sends) != TellCapPerHour {
|
||||
t.Fatalf("the 11th tell was sent: %d", len(tg.sends))
|
||||
}
|
||||
dropped, _ := out["dropped"].([]string)
|
||||
if len(dropped) != 1 || dropped[0] != "telegram" {
|
||||
t.Fatalf("dropped %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATestMessageRespectsTheRule(t *testing.T) {
|
||||
h, tg, dt, _, _ := newHolder(t)
|
||||
if out := h.Test("telegram", "see https://example.org"); out["refused"] == "" || len(tg.sends) != 0 {
|
||||
|
||||
@@ -645,6 +645,27 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear,
|
||||
strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved"))
|
||||
}},
|
||||
{Name: "operator-channel.tell",
|
||||
Description: "Tell the operator a plain notice that needs no answer and opens no condition (ADR 0380): a " +
|
||||
"headline and one or two lines, said at once on every channel, held to the content rule as notify's " +
|
||||
"words are, and offering Details like any message. Never escalated, never kept open, never reminded; " +
|
||||
"its own cap of 10 a channel an hour, apart from the conditions' and notify's.",
|
||||
Input: map[string]any{
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. new mail arrived for ace"),
|
||||
"lines": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "one or two plain lines"},
|
||||
"subject": str("what it is about: a machine's role, a module, a plan"),
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
var lines []string
|
||||
if raw, ok := a["lines"].([]any); ok {
|
||||
for _, l := range raw {
|
||||
if s, ok := l.(string); ok {
|
||||
lines = append(lines, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return h.Tell(strArg(a, "headline"), lines, strArg(a, "subject"))
|
||||
}},
|
||||
{Name: "messenger_status",
|
||||
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 " +
|
||||
|
||||
@@ -62,7 +62,7 @@ func readManifest(t *testing.T) (manifest, string) {
|
||||
|
||||
func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
verbs := []string{"open", "history", "notify", "asks"}
|
||||
verbs := []string{"open", "history", "notify", "tell", "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"}},
|
||||
|
||||
@@ -626,3 +626,26 @@ func (h *Holder) sentLastHour(channel string) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TellCapPerHour: at most this many tells reach one channel in an hour — the tell verb's own cap
|
||||
// (ADR 0380 §8), never shared with CapPerHour, the conditions' and notify's, so a burst of plain
|
||||
// notices cannot crowd out a condition or an ask.
|
||||
const TellCapPerHour = 10
|
||||
|
||||
// allowTell says whether a channel is under tell's own cap.
|
||||
func (h *Holder) allowTell(channel string) bool {
|
||||
return h.tellSentLastHour(channel) < TellCapPerHour
|
||||
}
|
||||
|
||||
func (h *Holder) tellSentLastHour(channel string) int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
since := h.Now().Add(-time.Hour)
|
||||
n := 0
|
||||
for _, s := range h.recent {
|
||||
if s.Channel == channel && s.What == "tell" && s.Outcome == "sent" && s.At.After(since) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -309,3 +311,76 @@ func (h *Holder) Notify(key, severity, summary, subject string, clear bool, plai
|
||||
held := h.queued(key)
|
||||
return map[string]any{"key": key, "sent_on": sent, "held": held, "refused": refused, "unsent": pending}, nil
|
||||
}
|
||||
|
||||
// Tell sends a plain notice that needs no answer and opens no condition (ADR 0380 §8): one message with a
|
||||
// headline and one or two lines, said at once on every channel the router sends on — bypassing the severity
|
||||
// routing, the burst window and the walk hold that a condition's message goes through, and never reminded,
|
||||
// escalated or kept open. Composed and held to the content rule exactly as notify's words are (allowedWords'
|
||||
// reduction, withheld parts as today). It offers Details, like any message, and is kept in the history so
|
||||
// `history` shows it — but its record is already marked cleared, so `open` never shows it and it is forgotten
|
||||
// after ReopenWindow, as any cleared record is. Its cap, TellCapPerHour a channel an hour, is its own and
|
||||
// never shared with CapPerHour: past it, a tell is counted as a drop in the history and said once in the log.
|
||||
func (h *Holder) Tell(headline string, lines []string, subject string) (map[string]any, error) {
|
||||
headline = strings.TrimSpace(headline)
|
||||
if headline == "" {
|
||||
return nil, errorf("headline is required")
|
||||
}
|
||||
if len(lines) == 0 || len(lines) > 2 {
|
||||
return nil, errorf("lines takes one or two lines")
|
||||
}
|
||||
clean := make([]string, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l == "" {
|
||||
return nil, errorf("a line is empty")
|
||||
}
|
||||
clean = append(clean, l)
|
||||
}
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
h.mu.Unlock()
|
||||
now := h.Now()
|
||||
h.mu.Lock()
|
||||
h.tellSeq++
|
||||
seq := h.tellSeq
|
||||
h.mu.Unlock()
|
||||
key := fmt.Sprintf("tell.%d.%d", now.UnixNano(), seq)
|
||||
rec := &Record{Key: key, Kind: "tell", Subject: subject, Origin: "tell", Headline: headline,
|
||||
Explanation: strings.Join(clean, "\n"), Summary: headline, Raised: now, Seen: now, Count: 1,
|
||||
Sent: map[string]string{}, Cleared: now}
|
||||
h.mu.Lock()
|
||||
h.open[key] = rec
|
||||
h.mu.Unlock()
|
||||
var sentOn, dropped []string
|
||||
for _, ch := range h.channels() {
|
||||
name := ch.Name()
|
||||
if ch.Ready() != nil {
|
||||
continue
|
||||
}
|
||||
if !h.allowTell(name) {
|
||||
dropped = append(dropped, name)
|
||||
h.Logf("[messenger] a tell was dropped on %s: it is at its own cap of %d tells an hour", name, TellCapPerHour)
|
||||
h.record(Sent{At: now, Channel: name, Key: key, What: "tell",
|
||||
Outcome: fmt.Sprintf("dropped: at its own cap of %d tells an hour", TellCapPerHour)})
|
||||
continue
|
||||
}
|
||||
m := h.sayOn(rec, "tell", name)
|
||||
id, err := ch.Send(m)
|
||||
h.noteChannel(name, err)
|
||||
if err != nil {
|
||||
h.record(Sent{At: now, Channel: name, Key: key, What: "tell", Outcome: "failed: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
rec.Sent[name] = id
|
||||
h.mu.Unlock()
|
||||
sentOn = append(sentOn, name)
|
||||
h.record(Sent{At: now, Channel: name, Key: key, What: "tell", Outcome: "sent"})
|
||||
}
|
||||
h.persist(rec)
|
||||
sort.Strings(sentOn)
|
||||
sort.Strings(dropped)
|
||||
return map[string]any{"key": key, "sent_on": sentOn, "dropped": dropped, "refused": rec.Refused}, nil
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold
|
||||
if urgent {
|
||||
title = "Urgent: " + title
|
||||
}
|
||||
case "tell":
|
||||
// A plain notice (ADR 0380 §1, §8): the headline and its one or two lines, nothing else — no
|
||||
// "since", no severity, no reminder: it waits for no answer and is never said again.
|
||||
return Message{Title: headline, Body: explanation}
|
||||
default:
|
||||
title = headline
|
||||
if urgent {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"open",
|
||||
"history",
|
||||
"notify",
|
||||
"tell",
|
||||
"asks"
|
||||
],
|
||||
"accepts": [
|
||||
@@ -62,6 +63,7 @@
|
||||
"open",
|
||||
"history",
|
||||
"notify",
|
||||
"tell",
|
||||
"asks"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user