Merge pull request 'One message per problem: a reminder replaces, a blip says nothing (issues 373 and 369)' (#176) from fix/373-one-message-per-condition into main

This commit was merged in pull request #176.
This commit is contained in:
2026-10-10 12:39:06 +00:00
18 changed files with 928 additions and 76 deletions
+5
View File
@@ -11,6 +11,11 @@ each machine the operator sits at (the `node-notifier` seat), and says which but
was made. A message with buttons stays until it is dismissed.
- **A notifier that started again** (`dunst.started`) is shown again every open message with buttons it showed.
- A message no machine took is said to the router as failed; the router tries it again.
- **One notification per message** (issue 373): an `edit`, and a `send` or `show` under a handle already shown (a
reminder, an escalation), replace that message's notification on each machine by its id (the notifier's
`replace_id`), so a problem is one notification updated in place. An edit goes only where it was shown; a
message said again is shown anew on a machine it was not. A replace that fails on a machine keeps that
machine's notification on record, so a later edit still reaches it.
## What the operator gives
+21 -3
View File
@@ -141,31 +141,49 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
machines = []string{on}
}
}
// **One notification per message** (issue 373): an edit, and a message said again under a handle already
// shown (a reminder, an escalation), replace the notification it had on each machine — dunst's replace id —
// so a problem is one notification updated in place, never one more beside it. An edit stays quiet where
// it was shown; a message said again is shown anew where it was not.
edit := parts[4] == asks.Edit
var before *Shown
if parts[4] == asks.Edit {
switch {
case edit:
var err error
if before, err = d.Store.Get(m.Handle); err != nil {
return err
}
case !m.Secret:
var err error
if before, err = d.Store.Get(m.Handle); err != nil {
d.Logf("[desk-channel] what is shown under this handle could not be read (%v): shown as a new message", err)
before = nil
}
}
s := Shown{Message: m, On: map[string]int{}, Sent: d.Now()}
var failed []string
shown := 0
for _, machine := range machines {
replace := 0
if before != nil {
replace = before.On[machine]
if replace == 0 {
if edit && replace == 0 {
continue // an edit goes where the message was shown, and nowhere new
}
}
id, err := d.show(machine, m, replace)
if err != nil {
failed = append(failed, machine+": "+err.Error())
if replace != 0 {
// Its notification is still there: kept, so a later edit or clearing still reaches it.
s.On[machine] = replace
}
continue
}
s.On[machine] = id
shown++
}
if len(s.On) == 0 {
if shown == 0 {
why := "no machine showed it"
if len(failed) > 0 {
why += " — " + strings.Join(failed, "; ")
@@ -259,3 +259,67 @@ func TestItRunsAsItsOwnAccount(t *testing.T) {
t.Errorf("the account %v, its state %v, its settings %v", account, state, settings)
}
}
// Issue 373: a message said again under its handle (a reminder, an escalation) replaces its notification on
// each machine — the notifier is called with that notification's replace id — and is shown anew only where it
// was not shown; a message under a new handle replaces nothing.
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop", "desktop")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, asks.Send, asks.Message{Handle: "m2", Title: "another problem"})
if len(*shown) != 4 {
t.Fatalf("shown %d", len(*shown))
}
for _, n := range *shown {
if _, has := n.args["replace_id"]; has {
t.Fatalf("a new message replaced a notification: %+v", n)
}
}
first := store["m1"].On
// The desk now names a third machine as well.
d.Machines = func() []string { return []string{"laptop", "desktop", "third"} }
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
again := (*shown)[4:]
if len(again) != 3 {
t.Fatalf("said again on %d machines", len(again))
}
for _, n := range again {
want, was := first[n.machine]
got, replaces := n.args["replace_id"]
switch {
case was && (!replaces || got != want):
t.Errorf("on %s the reminder did not replace notification %d: %v", n.machine, want, n.args)
case !was && replaces:
t.Errorf("on %s, where it was not shown, it replaced %v", n.machine, got)
}
}
if s := store["m1"]; len(s.On) != 3 || len(s.Message.Buttons) != 1 {
t.Fatalf("what is shown was not kept: %+v", s)
}
}
// The review of PR 176: a replace that fails on a machine keeps that machine's notification on record, so a
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed.
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop", "desktop")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
first := store["m1"].On
notify := d.Notify
d.Notify = func(machine string, args map[string]any) (json.RawMessage, error) {
if machine == "desktop" {
return nil, errors.New("no session answers")
}
return notify(machine, args)
}
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
if got := store["m1"].On["desktop"]; got != first["desktop"] {
t.Fatalf("the desktop's notification %d was forgotten: %v", first["desktop"], store["m1"].On)
}
d.Notify = func(string, map[string]any) (json.RawMessage, error) { return nil, errors.New("away") }
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[asks.IntakeSubject(asks.FailedWhat, Kind)]) != 1 {
t.Fatalf("shown nowhere, and not said as failed")
}
_ = shown
}
+14 -5
View File
@@ -114,11 +114,20 @@ warning kept quiet is also said once after a day.
| When | What |
|---|---|
| `condition-raised` | one message, deduplicated by the condition's key |
| still open after 1 h (urgent) or 12 h (warning) | once more — never sooner than that after it was first said |
| `condition-changed` from warning to urgent | once more, to both channels |
| `condition-cleared` | the first message edited to say so; said inside a digest, a line in the next one |
| cleared and raised again within 10 min | the same message, edited back to open — not a new one |
| silenced | nothing, its clearing included |
| still open after 1 h (urgent) or 12 h (warning) | once more, in place of its message — never sooner than that after it was last said |
| `condition-changed` from warning to urgent | once more, in place of its message, to both channels |
| `condition-cleared` | once it stays cleared 2 min (10 min once it came back): its message edited to say so; said inside a digest, a line in the next one |
| cleared and raised again before its clearing was said | nothing: to the operator it never ended — a unit failing again after each apply is one message, not one per apply; back as urgent, or with a reminder waiting when it cleared, that is said |
| cleared, said so, and raised again within 10 min | the same message, edited back to open — not a new one (urgent: said again in its place, so it rings) |
| silenced | its message, where shown, edited to "Silenced until <day>: <headline>" with the reason, no answers and no urgency (the desk's popup times out); nothing more while it lasts; silence over and still open, the message is open again; resolved meanwhile, said only where an edit notifies nobody |
**One message per problem** (issue 373). A record keeps the id of its message on each channel; a reminder or
an escalation is said **in place of it** — the channel takes the earlier one away and shows the new one as a
new message is shown, so it rings once and no second message stands beside the first (the desk replaces the
notification by its id; Telegram deletes the earlier message). A message said again goes alone, never as a
line of a digest beside the message it follows; one first said inside a digest gets a message of its own the
next time, and later words replace or edit that one. An ask's buttons live on that one message, so an ask that
ends takes them away everywhere; its expiry is said with its day when it is not today.
- **Routing:** urgent to every kind that can send; a warning to the desk where it can send, otherwise to
the channel that reaches the operator away; an ask to every kind that carries one of its answers.
@@ -11,6 +11,15 @@ import (
// open message", because each new message replaced the ticket of the one before it. Every message still
// shown keeps a Details of its own, used once, and Details never acts.
// shownOn is every message a channel was given: sent, or said again in place of an earlier one (issue 373).
func shownOn(ch *fakeChannel) []Message {
out := append([]Message(nil), ch.sends...)
for _, r := range ch.replaced {
out = append(out, r.m)
}
return out
}
func detailsOf(t *testing.T, m Message) string {
t.Helper()
tok := ticketFor(m, DetailsLabel)
@@ -47,10 +56,13 @@ func TestDetailsWorksOnEachOfThreeWarningsOnceEach(t *testing.T) {
settle(h, c)
}
}
if len(tg.sends) != 3 {
t.Fatalf("%d messages, want 3", len(tg.sends))
// Issue 373: one problem said again replaces its message rather than standing beside it; each of
// the three messages still has a Details of its own.
msgs := shownOn(tg)
if len(msgs) != 3 {
t.Fatalf("%d messages, want 3", len(msgs))
}
for _, m := range tg.sends {
for _, m := range msgs {
toks = append(toks, detailsOf(t, m))
}
if toks[0] == toks[1] || toks[1] == toks[2] || toks[0] == toks[2] {
@@ -188,10 +200,11 @@ func TestAnUnreadyChannelPushesOutNoShownDetails(t *testing.T) {
tg.notReady = nil
c.pass(2 * time.Minute)
h.Flush()
if len(tg.sends) != 2 {
t.Fatalf("%d messages, want 2", len(tg.sends))
msgs := shownOn(tg)
if len(msgs) != 2 {
t.Fatalf("%d messages, want 2", len(msgs))
}
for _, tk := range []string{tok, detailsOf(t, tg.sends[1])} {
for _, tk := range []string{tok, detailsOf(t, msgs[1])} {
if out, err := h.Chosen("telegram", choiceOf(tk, "42", c.now())); err != nil || out != "shown" {
t.Errorf("Details: %q %v", out, err)
}
@@ -292,7 +292,7 @@ func TestABurstIsOneMessage(t *testing.T) {
for i := 0; i < 3; i++ {
h.Condition(EventCleared, Condition{Key: fmt.Sprintf("seat.s%d.silent", i)})
}
runFor(h, c, time.Minute)
runFor(h, c, ClearSettle+time.Minute) // a clearing is said once it stays cleared (issue 373)
if len(tg.sends) != 2 || len(tg.edits) != 0 || !strings.HasPrefix(tg.sends[1].Title, "Mesh: 3 resolved") {
t.Fatalf("sends %d edits %d: %q", len(tg.sends), len(tg.edits), tg.sends[len(tg.sends)-1].Title)
}
+173 -32
View File
@@ -25,9 +25,11 @@ package main
//
// - On `condition-raised`: one message, deduplicated by the condition's key. Said again with the
// same key while open, it is the same message, not a second.
// - Once more if still open after 1 hour (urgent) or 12 hours (warning).
// - On `condition-cleared`: the first message is edited where the channel can (both can).
// Cleared and raised again within ten minutes, it is the same message, edited back to open.
// - Once more if still open after 1 hour (urgent) or 12 hours (warning), counted from when it was last
// said; a reminder or an escalation replaces its message, never stands beside it (issue 373).
// - On `condition-cleared`: once it stays cleared ClearSettle, its message is edited where the channel can
// (both can). Raised again before that, nothing is said: to the operator it never ended. Cleared, said
// so, and raised again within ten minutes, it is the same message, edited back to open.
// - A silenced condition sends nothing.
// - Urgent to both channels; warning to the desktop when the operator's session is there,
// otherwise to Telegram.
@@ -59,9 +61,13 @@ const (
// QuietRemindAfter: a warning kept quiet as needing nothing is said once after this.
QuietRemindAfter = 24 * time.Hour
ReopenWindow = 10 * time.Minute
CapPerHour = 20
KeptSends = 200
KeptRefusals = 50
// ClearSettle: a clearing is said once the condition stayed cleared this long. One raised again sooner
// (a unit failing again after each apply, issue 373) continues its message without a word, and its
// clearing is never said. Once it has come back, its next clearing waits the reopen window less a minute.
ClearSettle = 2 * time.Minute
CapPerHour = 20
KeptSends = 200
KeptRefusals = 50
// FreshFor: an event older than this by its own time is history — state, never a message.
FreshFor = 10 * time.Minute
@@ -120,6 +126,18 @@ type Channel interface {
CanEdit() bool
}
// replacer is a channel that can say a message again in place of the one it showed under that id: the earlier
// one goes, the new one is shown as a new message is — it rings as its kind rings (issue 373). A reminder or
// an escalation is then one message per problem, never one more beside it.
type replacer interface {
Replace(id string, m Message) error
}
func replaces(ch Channel) bool {
_, ok := ch.(replacer)
return ok
}
// silentEditor is a channel whose edits notify nobody (Telegram's are): what is over is said there
// even when it ended while the holder was away, since saying it costs the operator nothing.
type silentEditor interface{ SilentEdit() bool }
@@ -155,19 +173,36 @@ type Record struct {
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"`
SilencedTill time.Time `json:"silenced_till,omitempty"`
Sent map[string]string `json:"sent,omitempty"` // channel -> the first message's id; "" when said in a digest
FirstSent time.Time `json:"first_sent,omitempty"`
Reminded bool `json:"reminded,omitempty"`
Pending string `json:"pending,omitempty"` // what could not be said yet: raised, reminder, …
Folded bool `json:"folded,omitempty"` // held by the cap
Cleared time.Time `json:"cleared,omitempty"`
Count int `json:"count"`
Refused string `json:"refused,omitempty"`
Ask string `json:"ask,omitempty"`
Origin string `json:"origin"`
More string `json:"more"`
Raised time.Time `json:"raised"`
SilencedTill time.Time `json:"silenced_till,omitempty"`
// SilencedWhy is the reason the silence was given with; SilenceSaid: the message shown was edited to say
// it is silenced (issue 373: a critical popup silenced for a week stood on the desk as if still open).
SilencedWhy string `json:"silenced_why,omitempty"`
SilenceSaid bool `json:"silence_said,omitempty"`
Sent map[string]string `json:"sent,omitempty"` // channel -> its message's id; "" when said in a digest
FirstSent time.Time `json:"first_sent,omitempty"`
// LastSaid is when a message about it last went out: a reminder is due its bound after it, never sooner
// (an escalation followed by a reminder 31 s later, issue 373).
LastSaid time.Time `json:"last_said,omitempty"`
// ClearUnsaid: cleared, and the clearing not said yet — it is said once it stays cleared ClearSettle;
// raised again before, the message continues as if it never cleared. ClearSilent: said only where an edit
// notifies nobody (it ended while this holder was away).
ClearUnsaid bool `json:"clear_unsaid,omitempty"`
ClearSilent bool `json:"clear_silent,omitempty"`
// ClearHeld is what was waiting to be said of it when it cleared (a reminder, an escalation): said after
// all if it comes back before its clearing was said. Blips counts those comebacks, never said, so a
// condition that flaps is known as one and its message's "again, Nth time" counts only what was said.
ClearHeld string `json:"clear_held,omitempty"`
Blips int `json:"blips,omitempty"`
Reminded bool `json:"reminded,omitempty"`
Pending string `json:"pending,omitempty"` // what could not be said yet: raised, reminder, …
Folded bool `json:"folded,omitempty"` // held by the cap
Cleared time.Time `json:"cleared,omitempty"`
Count int `json:"count"`
Refused string `json:"refused,omitempty"`
// History: known from the state or from an old event, and never said — not news.
History bool `json:"history,omitempty"`
// Seen is when this holder last took word of it; the open set read after it is authoritative.
@@ -179,7 +214,7 @@ func recordOf(c Condition) Record {
return Record{Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary,
Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Needs: c.Needs, Actions: c.Actions,
Origin: "condition",
More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill}
More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill, SilencedWhy: c.SilencedWhy}
}
// takeWords keeps the plain words current, as the summary is: a condition's words follow what is seen.
@@ -440,13 +475,16 @@ func (h *Holder) raised(rec Record, quiet bool) {
// it was kept quiet as needing nothing and now needs the operator.
h.mu.Lock()
wasQuiet := needsNothing(old)
old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill
wasSilenced := old.silenced(now)
old.Summary, old.Subject, old.Kind = rec.Summary, rec.Subject, rec.Kind
old.SilencedTill, old.SilencedWhy = rec.SilencedTill, rec.SilencedWhy
old.takeWords(rec)
if rec.Severity != "" {
old.Severity = rec.Severity
}
old.Seen = now
h.mu.Unlock()
h.silenceWords(old, wasSilenced)
h.nowNeeded(old, wasQuiet, quiet)
h.persist(old)
return
@@ -454,14 +492,42 @@ func (h *Holder) raised(rec Record, quiet bool) {
if old != nil && now.Sub(old.Cleared) < ReopenWindow {
// Cleared and raised again within ten minutes: the same message, back to open (to-be 45 §2).
h.mu.Lock()
blip := old.ClearUnsaid
held := old.ClearHeld
escalated := old.Severity == Warning && rec.Severity == Urgent
gone := now.Sub(old.Cleared)
old.Cleared = time.Time{}
old.Count++
old.Summary, old.Severity, old.SilencedTill = rec.Summary, rec.Severity, rec.SilencedTill
old.ClearUnsaid, old.ClearSilent, old.ClearHeld = false, false, ""
if blip {
old.Blips++
} else {
old.Count++
}
old.Summary, old.Severity, old.SilencedTill, old.SilencedWhy = rec.Summary, rec.Severity, rec.SilencedTill, rec.SilencedWhy
old.takeWords(rec)
old.Seen = now
h.mu.Unlock()
if !quiet && !old.silenced(now) && old.told() {
switch {
case quiet || old.silenced(now):
case blip && escalated:
// Back before its clearing was said, and urgent now: said as an escalation, which rings.
h.deliver(old, "escalated")
case blip && held != "":
// Back before its clearing was said: what was waiting to be said of it when it cleared is said now.
h.deliver(old, held)
case blip:
// Its clearing was never said (issue 373): to the operator it never ended, so nothing is said now.
h.Logf("[messenger] %s back %s after it cleared, which was not said yet: the same message continues, nothing said",
old.Key, roughly(gone))
h.record(Sent{At: now, Channel: "-", Key: old.Key, What: "reopened", Outcome: "quiet: back before its clearing was said"})
case old.told() && old.Severity == Urgent:
// Its end was said, and it is back: urgent, it rings again, in place of its message.
h.deliver(old, "reopened")
case old.told():
h.edit(old, "reopened", false)
case !needsNothing(old) && !h.queued(old.Key):
// Cleared before it was ever said, and back: a problem the operator has not heard of is said.
h.deliver(old, "raised")
}
h.persist(old)
return
@@ -508,13 +574,16 @@ func (h *Holder) changed(rec Record, quiet bool) {
h.mu.Lock()
escalated := old.Severity == Warning && rec.Severity == Urgent
wasQuiet := needsNothing(old)
old.Summary, old.Subject, old.SilencedTill = rec.Summary, rec.Subject, rec.SilencedTill
wasSilenced := old.silenced(h.Now())
old.Summary, old.Subject = rec.Summary, rec.Subject
old.SilencedTill, old.SilencedWhy = rec.SilencedTill, rec.SilencedWhy
old.takeWords(rec)
if rec.Severity != "" {
old.Severity = rec.Severity
}
old.Seen = h.Now()
h.mu.Unlock()
h.silenceWords(old, wasSilenced)
switch {
case escalated && !quiet && !old.silenced(h.Now()):
// Routing differs for urgent: said once more, to both channels.
@@ -525,6 +594,33 @@ func (h *Holder) changed(rec Record, quiet bool) {
h.persist(old)
}
// silenceWords keeps a shown message true to a silence (issue 373). Silenced, the message is edited in place
// to say so — until when, and why — with no answers and no urgency, so the desk's popup times out instead of
// standing as if still open. When the silence ends and the condition is still open, the message is edited back
// to its open words, and the usual rules apply again.
func (h *Holder) silenceWords(r *Record, wasSilenced bool) {
now := h.Now()
h.mu.Lock()
silenced, told, said := r.silenced(now), r.told(), r.SilenceSaid
key, till, why := r.Key, r.SilencedTill, r.SilencedWhy
h.mu.Unlock()
switch {
case silenced && !wasSilenced && told && !said:
h.mu.Lock()
r.SilenceSaid = true
h.mu.Unlock()
h.Logf("[messenger] %s silenced until %s (%s): its message says so, and nothing more is said of it",
key, till.Format(time.RFC3339), why)
h.edit(r, "silenced", false)
case !silenced && said:
h.mu.Lock()
r.SilenceSaid = false
h.mu.Unlock()
h.Logf("[messenger] %s is no longer silenced and still open: its message says it is open again", key)
h.edit(r, "raised", false)
}
}
// cleared ends a record. Something never said is ended without a word; what was waiting to be said
// about it is dropped. quiet: it ended while this holder was not listening, so it is said only where
// an edit notifies nobody.
@@ -537,7 +633,11 @@ func (h *Holder) cleared(key string, quiet bool) {
h.Logf("[messenger] %s cleared and was not open here: nothing to say", key)
return
}
held := h.queuedWhat(key)
h.mu.Lock()
if held == "" {
held = old.Pending
}
old.Cleared = now
old.Pending = ""
old.Folded = false
@@ -546,18 +646,44 @@ func (h *Holder) cleared(key string, quiet bool) {
h.mu.Unlock()
dropped := h.unqueue(key)
switch {
case old.silenced(now):
case old.silenced(now) && !old.SilenceSaid:
// A silenced condition sends nothing, its clearing included.
case old.silenced(now):
// Its message says it is silenced: said resolved where an edit notifies nobody, so it does not stand.
h.mu.Lock()
old.ClearUnsaid, old.ClearSilent, old.ClearHeld = true, true, ""
h.mu.Unlock()
case !told:
if dropped > 0 {
h.Logf("[messenger] %s cleared before it was said: nothing to unsay", key)
}
default:
h.edit(old, "cleared", quiet)
// Said once it stays cleared (ClearSettle; a condition that came back, ReopenWindow): a blip of a few
// seconds is not said at all (issue 373).
h.mu.Lock()
old.ClearUnsaid, old.ClearSilent, old.ClearHeld = true, quiet, held
h.mu.Unlock()
}
h.persist(old)
}
// clearSettle is how long a record stays cleared before its clearing is said: one that came back before
// waits the whole reopen window, so a condition that flaps is said cleared once, when it stopped flapping.
func clearSettle(r *Record) time.Duration {
if r.Count > 1 || r.Blips > 0 {
return ReopenWindow - time.Minute
}
return ClearSettle
}
// lastSaid is when a message about a record last went out.
func lastSaid(r *Record) time.Time {
if !r.LastSaid.IsZero() {
return r.LastSaid
}
return r.FirstSent
}
// Tick does what time asks: reminders, retries, what is held, and forgetting what cleared long
// enough ago that a new raise is a new message.
func (h *Holder) Tick() {
@@ -578,7 +704,17 @@ func (h *Holder) Tick() {
for _, r := range recs {
switch {
case !r.Cleared.IsZero():
if now.Sub(r.Cleared) >= ReopenWindow {
if r.ClearUnsaid && now.Sub(r.Cleared) >= clearSettle(r) {
h.mu.Lock()
r.ClearUnsaid = false
silent := r.ClearSilent
h.mu.Unlock()
if !r.silenced(now) || r.SilenceSaid {
h.edit(r, "cleared", silent || r.silenced(now))
}
h.persist(r)
}
if !r.ClearUnsaid && now.Sub(r.Cleared) >= ReopenWindow && !h.queued(r.Key) {
h.mu.Lock()
delete(h.open, r.Key)
h.mu.Unlock()
@@ -589,6 +725,10 @@ func (h *Holder) Tick() {
}
}
case r.silenced(now):
case r.SilenceSaid:
// Its silence ran out while it is still open: the message is open again.
h.silenceWords(r, true)
h.persist(r)
case r.Pending != "" && !h.queued(r.Key):
// No channel could take it when it was said: tried again.
h.deliver(r, r.Pending)
@@ -601,9 +741,10 @@ func (h *Holder) Tick() {
h.deliver(r, "quiet-reminder")
h.persist(r)
case !r.Reminded && !r.FirstSent.IsZero() && now.Sub(r.Raised) >= remindAfter(r.Severity) &&
now.Sub(r.FirstSent) >= remindAfter(r.Severity):
now.Sub(lastSaid(r)) >= remindAfter(r.Severity):
// Once more after its bound — counted from its raising, and never sooner after it was
// first said (an old condition said at a reading of the state is not reminded at once).
// last said (an old condition said at a reading of the state is not reminded at once, nor one
// just escalated: issue 373).
h.mu.Lock()
r.Reminded = true
h.mu.Unlock()
@@ -642,7 +783,7 @@ func (h *Holder) say(r *Record, what string) Message { return h.sayOn(r, what, "
// 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" {
if what == "cleared" || what == "silenced" || m.Title == "A mesh message was withheld" {
return m
}
if r.Answered != "" {
@@ -714,7 +855,7 @@ func (h *Holder) askLines(rec *AskRecord) []string {
}
}
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
lines = append(lines, "If nobody answers by "+rec.Ask.Expires.In(h.zone()).Format("15:04")+": "+rec.Ask.OnExpiry+".")
lines = append(lines, "If nobody answers by "+by(rec.Ask.Expires, h.Now(), h.zone())+": "+rec.Ask.OnExpiry+".")
}
return lines
}
+60 -14
View File
@@ -17,6 +17,22 @@ type fakeChannel struct {
edits map[string]Message
n int
silent bool // edits notify nobody, as Telegram's
// replaced is each message said again in place of an earlier one, as the id it replaced and the message.
replaced []replacement
}
type replacement struct {
id string
m Message
}
// Replace says a message again in place of an earlier one: shown as a new message is, under the same id.
func (f *fakeChannel) Replace(id string, m Message) error {
if f.fail != nil {
return f.fail
}
f.replaced = append(f.replaced, replacement{id, m})
return nil
}
func (f *fakeChannel) SilentEdit() bool { return f.silent }
@@ -208,7 +224,7 @@ func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) {
}
func TestAMessageCarryingAnAddressIsRefusedAndSaidWithItsWordsWithheld(t *testing.T) {
h, tg, _, _, _ := newHolder(t)
h, tg, _, c, _ := newHolder(t)
var emitted []string
h.Emit = func(e string, _ any) error { emitted = append(emitted, e); return nil }
h.Condition(EventRaised, cond("provider.keycloak.ace.failing", Urgent, "cannot reach 192.168.1.20:8443 with token=abc"))
@@ -231,6 +247,8 @@ func TestAMessageCarryingAnAddressIsRefusedAndSaidWithItsWordsWithheld(t *testin
}
// Its clearing keeps the words withheld.
h.Condition(EventCleared, Condition{Key: "provider.keycloak.ace.failing"})
c.pass(ClearSettle)
h.Tick()
for _, m := range tg.edits {
if strings.Contains(m.Text(), "192.168") {
t.Fatalf("the clearing carried it: %q", m.Text())
@@ -262,24 +280,26 @@ func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) {
settle(h, c)
c.pass(59 * time.Minute)
h.Tick()
if len(tg.sends) != 2 {
t.Fatalf("reminded before the hour: %d", len(tg.sends))
if len(tg.sends) != 2 || len(tg.replaced) != 0 {
t.Fatalf("reminded before the hour: %d %d", len(tg.sends), len(tg.replaced))
}
c.pass(2 * time.Minute)
h.Tick()
if len(tg.sends) != 3 || (tg.sends[2].Title != "Still open: bus controller slow-consumer" || !strings.Contains(tg.sends[2].Body, "for 61 min")) {
t.Fatalf("urgent reminder: %d %q", len(tg.sends), tg.sends[len(tg.sends)-1].Title)
// Said once more in place of its first message (issue 373): one message per problem.
if len(tg.sends) != 2 || len(tg.replaced) != 1 || tg.replaced[0].id != "1" ||
tg.replaced[0].m.Title != "Still open: bus controller slow-consumer" || !strings.Contains(tg.replaced[0].m.Body, "for 61 min") {
t.Fatalf("urgent reminder: %d sends, %+v", len(tg.sends), tg.replaced)
}
c.pass(3 * time.Hour)
h.Tick()
if len(tg.sends) != 3 {
if len(tg.replaced) != 1 {
t.Fatalf("reminded twice")
}
c.pass(9 * time.Hour) // the warning is now 13 h old
h.Tick()
settle(h, c)
if len(tg.sends) != 4 || tg.sends[3].Title != "Still open: plan 41 stalled" {
t.Fatalf("warning reminder: %d", len(tg.sends))
if len(tg.sends) != 2 || len(tg.replaced) != 2 || tg.replaced[1].id != "2" || tg.replaced[1].m.Title != "Still open: plan 41 stalled" {
t.Fatalf("warning reminder: %d sends, %+v", len(tg.sends), tg.replaced)
}
}
@@ -290,6 +310,8 @@ func TestClearedEditsTheFirstMessageAndReopenedWithinTenMinutesIsNotNew(t *testi
h.Condition(EventRaised, k)
c.pass(14 * time.Minute)
h.Condition(EventCleared, Condition{Key: k.Key})
c.pass(ClearSettle) // said once it stays cleared (issue 373)
h.Tick()
if len(tg.sends) != 1 || len(tg.edits) != 1 || len(dt.edits) != 1 {
t.Fatalf("telegram sends %d edits %d, desktop edits %d", len(tg.sends), len(tg.edits), len(dt.edits))
}
@@ -299,11 +321,13 @@ func TestClearedEditsTheFirstMessageAndReopenedWithinTenMinutesIsNotNew(t *testi
if len(h.Open()) != 0 {
t.Fatalf("still open")
}
c.pass(5 * time.Minute)
c.pass(3 * time.Minute)
k.At = c.now() // a reopening keeps its first raising time; the event says when it happened
h.Condition(EventRaised, k)
if len(tg.sends) != 1 || !strings.Contains(tg.edits["1"].Title, "(again, 2nd time)") {
t.Fatalf("reopened as new: sends %d, edit %q", len(tg.sends), tg.edits["1"].Title)
// Urgent, and its end was said: it rings again, in place of its message (issue 373), never as a new one.
if len(tg.sends) != 1 || len(tg.replaced) != 1 || tg.replaced[0].id != "1" ||
!strings.Contains(tg.replaced[0].m.Title, "(again, 2nd time)") {
t.Fatalf("reopened as new: sends %d, replaced %+v", len(tg.sends), tg.replaced)
}
h.Condition(EventCleared, Condition{Key: k.Key})
c.pass(11 * time.Minute)
@@ -351,8 +375,10 @@ func TestEscalationIsSaidOnce(t *testing.T) {
h.Condition(EventChanged, k)
h.Condition(EventChanged, k)
settle(h, c)
if len(dt.sends) != 2 || len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Now urgent: ") {
t.Fatalf("desktop %d telegram %d", len(dt.sends), len(tg.sends))
// The desktop's warning is replaced by the urgent message (issue 373); Telegram, which had none, is sent one.
if len(dt.sends) != 1 || len(dt.replaced) != 1 || dt.replaced[0].id != "g14=1" ||
!strings.HasPrefix(dt.replaced[0].m.Title, "Now urgent: ") || len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Now urgent: ") {
t.Fatalf("desktop %d sends %d replaced, telegram %d", len(dt.sends), len(dt.replaced), len(tg.sends))
}
// Escalated while the warning still waited its window: one message, the urgent one.
k2 := cond("machine.ace.silent", Warning, "the home server is silent")
@@ -360,9 +386,23 @@ func TestEscalationIsSaidOnce(t *testing.T) {
k2.Severity = Urgent
h.Condition(EventChanged, k2)
settle(h, c)
if len(dt.sends) != 3 || !strings.HasPrefix(dt.sends[2].Title, "Now urgent: ") {
if len(dt.sends) != 2 || !strings.HasPrefix(dt.sends[1].Title, "Now urgent: ") || len(dt.replaced) != 1 {
t.Fatalf("desktop %d: %q", len(dt.sends), dt.sends[len(dt.sends)-1].Title)
}
// Escalated, then the hour of an urgent one passes since it was raised: no reminder until an hour after the
// escalation was said (issue 373: "Now urgent" and "Still open" 31 s apart).
c.pass(59 * time.Minute)
h.Tick()
settle(h, c)
if len(dt.replaced) != 1 || len(tg.replaced) != 0 {
t.Fatalf("reminded within the hour of the escalation: %+v %+v", dt.replaced, tg.replaced)
}
c.pass(2 * time.Minute)
h.Tick()
settle(h, c)
if len(tg.replaced) != 2 || len(tg.sends) != 2 {
t.Fatalf("not reminded an hour after the escalation, in place: telegram %d sends %+v", len(tg.sends), tg.replaced)
}
}
func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
@@ -411,6 +451,12 @@ func TestARestartForgetsNothing(t *testing.T) {
t.Fatalf("a restart sent it again")
}
again.Condition(EventCleared, Condition{Key: k.Key})
third := &Holder{Away: tg, Store: st, Now: c.now, Logf: t.Logf}
if err := third.Load(); err != nil {
t.Fatal(err)
}
c.pass(ClearSettle)
third.Tick() // a clearing not said yet before a restart is said after it
if len(tg.edits) != 1 {
t.Fatalf("a restart lost the message to edit")
}
@@ -0,0 +1,377 @@
package main
// Issue 373: the operator no longer trusted the mesh's notifications — one problem shown twice, a unit that
// failed again at each apply said again each time, a question still offered after it ended. One message per
// problem at any time, updated in place.
import (
"strings"
"testing"
"time"
)
func saidOn(chs ...*fakeChannel) int {
n := 0
for _, ch := range chs {
n += len(ch.sends) + len(ch.edits) + len(ch.replaced)
}
return n
}
// A unit that fails again seconds after each apply clears it: the message continues without a word — no
// "again, 22nd time", no "Resolved" — and once it stays cleared, its clearing is said once.
func TestAFlappingConditionIsSaidOnceAndItsClearingOnceWhenItStops(t *testing.T) {
h, tg, dt, c, _ := newHolder(t)
k := cond("machine.shanks.units", Warning, "2 failed services on shanks")
k.Raised = c.now()
h.Condition(EventRaised, k)
settle(h, c)
if len(dt.sends) != 1 || saidOn(tg) != 0 {
t.Fatalf("raised: desktop %d, telegram %d", len(dt.sends), saidOn(tg))
}
for i := 0; i < 22; i++ {
c.pass(3 * time.Minute)
h.Tick()
cl := Condition{Key: k.Key, At: c.now()}
h.Condition(EventCleared, cl)
c.pass(15 * time.Second)
h.Flush()
k.At = c.now()
h.Condition(EventRaised, k)
h.Flush()
}
runFor(h, c, 20*time.Minute)
if got := saidOn(tg, dt); got != 1 {
t.Fatalf("a flapping condition was said %d times; want once: desktop %+v %+v %+v", got, dt.sends, dt.edits, dt.replaced)
}
// Once it has come back, a longer gap — past ClearSettle, inside the reopen window — is still a blip.
h.Condition(EventCleared, Condition{Key: k.Key, At: c.now()})
runFor(h, c, 5*time.Minute)
k.At = c.now()
h.Condition(EventRaised, k)
runFor(h, c, time.Minute)
if got := saidOn(tg, dt); got != 1 {
t.Fatalf("a condition that flaps slower was said again: %d", got)
}
// Its comebacks were never said: the count the operator reads ("again, Nth time") does not grow with them.
if open := h.Open(); len(open) != 1 || open[0].Count != 1 {
t.Fatalf("open: %+v", open)
}
// A genuinely new problem meanwhile is said.
n := cond("machine.ace.disk-full", Urgent, "the home server's disk is full")
n.Raised = c.now()
h.Condition(EventRaised, n)
if len(tg.sends) != 1 || len(dt.sends) != 2 {
t.Fatalf("a new problem was swallowed: telegram %d desktop %d", len(tg.sends), len(dt.sends))
}
// The unit stays mended: its clearing is said once, in place of its message.
h.Condition(EventCleared, Condition{Key: k.Key, At: c.now()})
runFor(h, c, 30*time.Minute)
if len(dt.edits) != 1 || !strings.HasPrefix(dt.edits["g14=1"].Title, "Resolved: ") || len(dt.sends) != 2 {
t.Fatalf("its clearing: edits %+v, sends %d", dt.edits, len(dt.sends))
}
}
// A clearing a few seconds long is never said, and the message continues; an urgent problem rang once.
func TestAnUrgentBlipRingsOnceAndItsBlipsSayNothing(t *testing.T) {
h, tg, dt, c, _ := newHolder(t)
k := cond("machine.ace.silent", Urgent, "the home server is silent")
k.Raised = c.now()
h.Condition(EventRaised, k)
for i := 0; i < 5; i++ {
c.pass(time.Minute)
h.Condition(EventCleared, Condition{Key: k.Key, At: c.now()})
c.pass(20 * time.Second)
h.Tick()
k.At = c.now()
h.Condition(EventRaised, k)
}
runFor(h, c, 10*time.Minute)
if len(tg.sends) != 1 || len(dt.sends) != 1 || len(tg.edits)+len(dt.edits)+len(tg.replaced)+len(dt.replaced) != 0 {
t.Fatalf("telegram %d %d %d, desktop %d %d %d", len(tg.sends), len(tg.edits), len(tg.replaced), len(dt.sends),
len(dt.edits), len(dt.replaced))
}
}
// Cleared before it was ever said, and back within the reopen window: a problem the operator never heard of is
// said, not swallowed as the same message.
func TestAConditionClearedBeforeItWasSaidAndBackIsSaid(t *testing.T) {
h, _, dt, c, _ := newHolder(t)
k := cond("machine.shanks.units", Warning, "2 failed services on shanks")
k.Raised = c.now()
h.Condition(EventRaised, k)
c.pass(10 * time.Second)
h.Condition(EventCleared, Condition{Key: k.Key, At: c.now()})
c.pass(time.Minute)
k.At = c.now()
h.Condition(EventRaised, k)
runFor(h, c, 2*time.Minute)
if len(dt.sends) != 1 || !strings.Contains(dt.sends[0].Title, "machine shanks units") {
t.Fatalf("desktop %+v", dt.sends)
}
}
// Said first inside a digest, a problem's next message is one of its own, and the word after that replaces or
// edits that one: never a second message beside it.
func TestAProblemSaidInADigestGetsAMessageOfItsOwnThatLaterWordsReplace(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desk = nil
a := cond("seat.s1.silent", Warning, "s1 is silent")
b := cond("seat.s2.silent", Warning, "s2 is silent")
a.Raised, b.Raised = c.now(), c.now()
h.Condition(EventRaised, a)
h.Condition(EventRaised, b)
settle(h, c)
if len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Mesh: 2 new problems") {
t.Fatalf("sends %+v", tg.sends)
}
b.Severity = Urgent
h.Condition(EventChanged, b)
settle(h, c)
if len(tg.sends) != 2 || !strings.HasPrefix(tg.sends[1].Title, "Now urgent: ") {
t.Fatalf("escalation: %+v", tg.sends)
}
h.Condition(EventCleared, Condition{Key: b.Key, At: c.now()})
c.pass(ClearSettle)
h.Tick()
settle(h, c)
if len(tg.sends) != 2 || !strings.HasPrefix(tg.edits["2"].Title, "Resolved: seat s2") {
t.Fatalf("its clearing was not an edit of its own message: sends %d, edits %+v", len(tg.sends), tg.edits)
}
}
// An ask's expiry is said with its day when it is not today: an ask lasting a week read as ending tonight.
func TestAnAsksExpiryIsSaidWithItsDay(t *testing.T) {
now := time.Date(2026, 10, 9, 21, 33, 0, 0, time.UTC)
for at, want := range map[time.Time]string{
now.Add(time.Hour): "22:33",
now.Add(24 * time.Hour): "tomorrow 21:33",
now.Add(7 * 24 * time.Hour): "Fri 16 Oct 21:33",
} {
if got := by(at, now, time.UTC); got != want {
t.Errorf("%s: %q, want %q", at, got, want)
}
}
h, _, _, c, _ := newHolder(t)
h.Zone = func() *time.Location { return time.UTC }
c.t = now
rec := &AskRecord{Asker: ConditionOwner}
rec.Ask.Expires = now.Add(7 * 24 * time.Hour)
rec.Ask.OnExpiry = "nothing is done"
lines := strings.Join(h.askLines(rec), "\n")
if !strings.Contains(lines, "If nobody answers by Fri 16 Oct 21:33: nothing is done.") {
t.Fatalf("%s", lines)
}
}
// A question on a problem's message, then its reminder: when the question ends, the one message the operator
// has loses its buttons and says so — none is left offering an answer nobody takes.
func TestAnEndedQuestionLeavesNoMessageOfferingItsAnswers(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
k := cond("machine.novox.secret-given", Urgent, "a secret was given at the terminal")
k.Raised = c.now()
h.Condition(EventRaised, k)
settle(h, c)
a := ackAsk(c, "q-1")
a.About = k.Key
a.Expires = c.now().Add(3 * time.Hour)
submit(t, h, ConditionOwner, a)
settle(h, c)
for i := 0; i < 4*60; i++ {
c.pass(time.Minute)
stand(h, c)
h.Tick()
h.Flush()
}
if rec := store.asks[ConditionOwner+".q-1"]; rec.State == StateOpen {
t.Fatalf("the ask did not expire: %+v", rec.State)
}
for _, ch := range []*fakeChannel{tg, dt} {
if len(ch.sends) != 1 {
t.Fatalf("%s: %d messages for one problem", ch.name, len(ch.sends))
}
last := ch.edits[map[string]string{"telegram": "1", "desktop": "g14=1"}[ch.name]]
if len(ch.replaced) == 0 || len(last.Actions) > 1 || !strings.Contains(last.Body, "No longer answerable") {
t.Fatalf("%s: replaced %d, the message now: %q %v", ch.name, len(ch.replaced), last.Text(), labelsOf(last))
}
}
}
// The review of PR 176: a comeback before the clearing was said must not swallow what changed meanwhile — a
// warning back as urgent rings, and a reminder waiting to go out when it cleared still goes out.
func TestABlipSwallowsNeitherAnEscalationNorAWaitingReminder(t *testing.T) {
h, tg, dt, c, _ := newHolder(t)
k := cond("machine.shanks.units", Warning, "2 failed services on shanks")
k.Raised = c.now()
h.Condition(EventRaised, k)
settle(h, c)
if len(dt.sends) != 1 || len(tg.sends) != 0 {
t.Fatalf("raised: desktop %d telegram %d", len(dt.sends), len(tg.sends))
}
h.Condition(EventCleared, Condition{Key: k.Key, At: c.now()})
c.pass(20 * time.Second)
k.Severity, k.At = Urgent, c.now()
h.Condition(EventRaised, k)
settle(h, c)
if len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Now urgent: ") {
t.Fatalf("back as urgent, and Telegram was not rung: %+v", tg.sends)
}
// A warning's reminder held by the desk's gentle rhythm when it cleared: said when it comes back.
w := cond("plan.41.stalled", Warning, "plan 41 waits")
w.Raised = c.now()
h.Condition(EventRaised, w)
runFor(h, c, DesktopWarningEvery+time.Minute)
before := len(dt.sends) + len(dt.replaced)
c.pass(RemindWarning)
h.Tick() // the reminder is due, and the desk holds it: a warning went out there less than 15 min ago
h.Condition(EventRaised, cond("seat.s9.silent", Warning, "s9 is silent"))
h.Flush()
h.Condition(EventCleared, Condition{Key: w.Key, At: c.now()})
c.pass(20 * time.Second)
w.At = c.now()
h.Condition(EventRaised, w)
runFor(h, c, DesktopWarningEvery+time.Minute)
found := false
for _, r := range dt.replaced {
found = found || strings.HasPrefix(r.m.Title, "Still open: plan 41")
}
for _, m := range dt.sends {
found = found || strings.Contains(m.Body, "plan 41 stalled") || strings.HasPrefix(m.Title, "Still open: plan 41")
}
if !found || len(dt.sends)+len(dt.replaced) == before {
t.Fatalf("the reminder waiting when it blipped was lost: sends %+v replaced %+v", dt.sends, dt.replaced)
}
}
// The review of PR 176: a reminder for one problem and a new problem in the same burst — the reminder goes
// alone in place of its message, never as a digest line beside it.
func TestAReminderInABurstReplacesItsMessageAndIsNoDigestLine(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desk = nil
a := cond("plan.41.stalled", Warning, "plan 41 waits")
a.Raised = c.now()
h.Condition(EventRaised, a)
settle(h, c)
c.pass(RemindWarning)
h.Tick()
b := cond("seat.s9.silent", Warning, "s9 is silent")
b.Raised = c.now()
h.Condition(EventRaised, b)
settle(h, c)
for _, m := range tg.sends {
if strings.HasPrefix(m.Title, "Mesh: ") {
t.Fatalf("a reminder went into a digest: %q", m.Text())
}
}
if len(tg.replaced) != 1 || tg.replaced[0].id != "1" || len(tg.sends) != 2 {
t.Fatalf("sends %+v, replaced %+v", tg.sends, tg.replaced)
}
}
// The review of PR 176: a clearing the channel could not take is kept until it is said, past the reopen window.
func TestAClearingTheChannelCouldNotTakeIsSaidLater(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desk = nil
k := cond("machine.ace.silent", Urgent, "the home server is silent")
k.Raised = c.now()
h.Condition(EventRaised, k)
tg.fail = errorf("telegram is away")
h.Condition(EventCleared, Condition{Key: k.Key, At: c.now()})
runFor(h, c, ReopenWindow+2*time.Minute)
tg.fail = nil
runFor(h, c, 2*time.Minute)
for _, m := range tg.sends {
if strings.HasPrefix(m.Title, "Resolved: ") {
return
}
}
t.Fatalf("the clearing was lost: %+v", tg.sends)
}
// Issue 373, seen at 14:40: a condition silenced for a week with a reason left its critical popup standing on
// the desk as if still open, and the router logged nothing. A silence edits the shown message in place to say
// so — until when, and why — with no answers and no urgency, so the desk's popup times out; nothing more is
// said while it lasts; when the silence ends and the condition is still open, the message is open again.
func TestASilenceIsSaidOnTheShownMessageAndTheMessageIsOpenAgainWhenItEnds(t *testing.T) {
h, tg, dt, c, _ := newHolder(t)
h.Zone = func() *time.Location { return time.UTC }
k := cond("machine.shanks.units", Warning, "2 failed services on shanks")
k.Raised = c.now()
h.Condition(EventRaised, k)
settle(h, c)
if len(dt.sends) != 1 || len(dt.sends[0].Actions) == 0 {
t.Fatalf("raised: %+v", dt.sends)
}
c.pass(time.Hour)
k.SilencedTill, k.SilencedWhy = c.now().Add(7*24*time.Hour), "the mounts are gone for good"
h.Condition(EventChanged, k)
m, edited := dt.edits["g14=1"]
if !edited || m.Title != "Silenced until Tue 13 Oct 13:00: machine shanks units" || !m.Quiet || m.Urgent ||
len(m.Actions) != 0 || !strings.Contains(m.Body, "Because: the mounts are gone for good.") {
t.Fatalf("silenced, and the desk's message says %q %q quiet=%v urgent=%v actions=%v", m.Title, m.Body, m.Quiet, m.Urgent, m.Actions)
}
if len(tg.sends)+len(tg.edits)+len(tg.replaced) != 0 {
t.Fatalf("a silence reached a channel the message was not on")
}
// Nothing more while it lasts: no reminder, and the same silence said again edits nothing more.
h.Condition(EventChanged, k)
runFor(h, c, 13*time.Hour)
if len(dt.sends) != 1 || len(dt.replaced) != 0 || len(dt.edits) != 1 {
t.Fatalf("said again while silenced: sends %d replaced %d edits %d", len(dt.sends), len(dt.replaced), len(dt.edits))
}
// The silence runs out and the condition is still open: its message is open again, on the same notification.
c.pass(7 * 24 * time.Hour)
h.Tick()
if m := dt.edits["g14=1"]; m.Title != "machine shanks units" || m.Quiet || len(m.Actions) == 0 {
t.Fatalf("the silence ended, and the message says %q quiet=%v actions=%v", m.Title, m.Quiet, m.Actions)
}
if len(dt.sends) != 1 {
t.Fatalf("the silence ending made a new message: %d", len(dt.sends))
}
}
// Silenced on both channels and then resolved: Telegram's message, whose edits notify nobody, says so; the
// desk's popup, which timed out, is not brought back.
func TestAConditionResolvedWhileSilencedSaysSoOnlyWhereAnEditNotifiesNobody(t *testing.T) {
h, tg, dt, c, _ := newHolder(t)
k := cond("machine.ace.silent", Urgent, "the home server is silent")
k.Raised = c.now()
h.Condition(EventRaised, k)
k.SilencedTill, k.SilencedWhy = c.now().Add(24*time.Hour), "ace is moving house"
h.Condition(EventChanged, k)
if !strings.HasPrefix(tg.edits["1"].Title, "Silenced until ") || !strings.HasPrefix(dt.edits["g14=1"].Title, "Silenced until ") {
t.Fatalf("telegram %q desk %q", tg.edits["1"].Title, dt.edits["g14=1"].Title)
}
h.Condition(EventCleared, Condition{Key: k.Key, At: c.now()})
runFor(h, c, ClearSettle+time.Minute)
if !strings.HasPrefix(tg.edits["1"].Title, "Resolved: ") || !strings.HasPrefix(dt.edits["g14=1"].Title, "Silenced until ") {
t.Fatalf("resolved while silenced: telegram %q desk %q", tg.edits["1"].Title, dt.edits["g14=1"].Title)
}
if len(tg.sends)+len(dt.sends) != 2 || len(tg.replaced)+len(dt.replaced) != 0 {
t.Fatalf("a new message was made: sends %d %d", len(tg.sends), len(dt.sends))
}
}
// A silence given while this holder was not told (the 10-min reading of the open set carries it) is said the
// same way, and the reading that says it ended makes the message open again.
func TestASilenceReadFromTheOpenSetIsSaidOnTheShownMessage(t *testing.T) {
h, _, dt, c, _ := newHolder(t)
k := cond("machine.shanks.units", Warning, "2 failed services on shanks")
k.Raised = c.now()
h.Condition(EventRaised, k)
settle(h, c)
c.pass(10 * time.Minute)
s := k
s.SilencedTill, s.SilencedWhy = c.now().Add(7*24*time.Hour), "the mounts are gone for good"
h.Sync([]Condition{s}, c.now())
if m := dt.edits["g14=1"]; !strings.HasPrefix(m.Title, "Silenced until ") || !m.Quiet || len(m.Actions) != 0 {
t.Fatalf("read silenced, and the desk's message says %q quiet=%v actions=%v", m.Title, m.Quiet, m.Actions)
}
c.pass(10 * time.Minute)
h.Sync([]Condition{k}, c.now()) // the silence was lifted: the reading carries none
if m := dt.edits["g14=1"]; m.Title != "machine shanks units" || len(dt.sends) != 1 {
t.Fatalf("read unsilenced, and the desk's message says %q (sends %d)", m.Title, len(dt.sends))
}
}
+48 -9
View File
@@ -104,6 +104,20 @@ func (h *Holder) unqueue(key string) int {
return n
}
// queuedWhat is what is held to be said of a record, not its clearing: "" when nothing is.
func (h *Holder) queuedWhat(key string) string {
h.mu.Lock()
defer h.mu.Unlock()
for _, list := range h.outbox {
for _, it := range list {
if it.Msg == nil && it.Key == key && it.What != "cleared" {
return it.What
}
}
}
return ""
}
func (h *Holder) queued(key string) bool {
h.mu.Lock()
defer h.mu.Unlock()
@@ -294,7 +308,9 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
// an ask whose approval was not offered folded into a digest, and its one answer lost with it).
var alone, rest []entry
for _, e := range entries {
if len(e.msg.Actions) > 1 || (len(e.msg.Actions) > 0 && e.r != nil && e.r.Ask != "") {
// A message said again about a record shown here goes alone too: it replaces that record's message,
// so a reminder is never a digest line beside the message it follows (issue 373).
if len(e.msg.Actions) > 1 || (len(e.msg.Actions) > 0 && e.r != nil && e.r.Ask != "") || h.replacing(ch, e) != "" {
alone = append(alone, e)
} else {
rest = append(rest, e)
@@ -329,12 +345,26 @@ type entry struct {
msg Message
}
// sendEntries sends entries as one message — itself when alone, a digest when several — and records it.
// replacing is the id of the message an entry replaces on a channel: the record's own message there, when the
// entry says it again (not its clearing) and the channel can replace; "" for a new message.
func (h *Holder) replacing(ch Channel, e entry) string {
if e.r == nil || e.it.What == "cleared" || !replaces(ch) {
return ""
}
h.mu.Lock()
defer h.mu.Unlock()
return e.r.Sent[ch.Name()]
}
// sendEntries sends entries as one message — itself when alone, a digest when several — and records it. A
// record said again where its message is shown replaces that message (issue 373): one message per problem.
func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool {
name := ch.Name()
var m Message
prior := ""
if len(entries) == 1 {
m = entries[0].msg
prior = h.replacing(ch, entries[0])
} else {
items := make([]item, len(entries))
msgs := make([]Message, len(entries))
@@ -343,7 +373,13 @@ func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool {
}
m = digest(items, msgs)
}
id, err := ch.Send(m)
var id string
var err error
if prior != "" {
id, err = prior, ch.(replacer).Replace(prior, m)
} else {
id, err = ch.Send(m)
}
h.noteChannel(name, err)
if err != nil {
return false
@@ -367,22 +403,25 @@ func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool {
if r.Sent == nil {
r.Sent = map[string]string{}
}
if _, has := r.Sent[name]; !has {
own := ""
if len(entries) == 1 {
own = id
}
r.Sent[name] = own
if len(entries) == 1 {
// Its own message: the one later words replace or edit — also after it was said in a digest.
r.Sent[name] = id
} else if _, has := r.Sent[name]; !has {
r.Sent[name] = ""
}
if r.FirstSent.IsZero() {
r.FirstSent = now
}
r.LastSaid = now
}
}
h.mu.Unlock()
key, what := "(digest)", fmt.Sprintf("digest of %d", len(entries))
if len(entries) == 1 {
key, what = entries[0].it.Key, entries[0].it.What
if prior != "" {
what += ", in place of its earlier message"
}
}
h.record(Sent{At: now, Channel: name, Key: key, What: what, Outcome: "sent"})
for _, e := range entries {
@@ -243,6 +243,15 @@ func (c *SeatChannel) Send(m Message) (string, error) {
return handle, c.put(handle, m, false)
}
// Replace says a message again under the handle of one shown before: the channel takes the earlier one away
// and shows this one as a new message is shown — ringing as its kind rings (issue 373). An edit is quiet.
func (c *SeatChannel) Replace(handle string, m Message) error {
if handle == "" {
return errors.New("no message on record to replace")
}
return c.put(handle, m, false)
}
// Edit replaces the message a handle names.
func (c *SeatChannel) Edit(handle string, m Message) error {
if handle == "" {
@@ -311,6 +320,13 @@ func (c *KindChannel) Send(m Message) (string, error) {
handle := newHandle()
return handle, s.putOn(c.Kind, handle, m, false)
}
func (c *KindChannel) Replace(handle string, m Message) error {
if handle == "" {
return errors.New("no message on record to replace")
}
s := &SeatChannel{Bench: c.Bench, Publish: c.Publish}
return 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)
+1 -1
View File
@@ -113,7 +113,7 @@ func (h *Holder) Status(listening string) Status {
"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; a silence is the controller's, asked of the operator or set through the mesh",
"a silenced condition's message says it is silenced, until when and why, and nothing more is said of it while the silence lasts; a silence is the controller's, asked of the operator or set through the mesh",
}
anyCan := false
for _, c := range st.Channels {
+8 -2
View File
@@ -57,7 +57,7 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult {
old := h.open[c.Key]
var r *Record
known := old != nil
wasQuiet := false
wasQuiet, wasSilenced := false, false
switch {
case old != nil && old.Seen.After(asked):
h.mu.Unlock()
@@ -65,12 +65,15 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult {
continue
case old != nil:
wasQuiet = needsNothing(old) && !old.told()
wasSilenced = old.silenced(now)
if !old.Cleared.IsZero() {
// Ended here, open there: back to open, quietly — the reading is not an event.
old.Cleared = time.Time{}
old.ClearUnsaid, old.ClearSilent, old.ClearHeld = false, false, ""
old.Count++
}
old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill
old.Summary, old.Subject, old.Kind = rec.Summary, rec.Subject, rec.Kind
old.SilencedTill, old.SilencedWhy = rec.SilencedTill, rec.SilencedWhy
old.takeWords(rec)
if rec.Severity != "" {
old.Severity = rec.Severity
@@ -94,6 +97,9 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult {
told := r.told()
silenced := r.silenced(now)
h.mu.Unlock()
if known {
h.silenceWords(r, wasSilenced)
}
switch {
case told || silenced || h.queued(r.Key):
case r.Severity == Urgent:
+24
View File
@@ -51,6 +51,13 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold
title = "Still open after a day: " + headline
case "escalated":
title = "Now urgent: " + headline
case "silenced":
// Said in place of the open message: no urgency, so the desk's popup times out, and no answers.
body := "Nothing more is said of it until then."
if r.SilencedWhy != "" {
body = "Because: " + strings.TrimSuffix(strings.TrimSpace(r.SilencedWhy), ".") + ". " + body
}
return Message{Title: "Silenced until " + by(r.SilencedTill, now, loc) + ": " + headline, Body: body, Quiet: true}
case "reopened":
title = headline + " (again" + times(r.Count) + ")"
if urgent {
@@ -198,6 +205,23 @@ func when(t, now time.Time, loc *time.Location) string {
return lt.Format("2 Jan 15:04")
}
// by is a moment ahead as the operator reads it, in their zone: "23:33" today, "tomorrow 23:33", "Fri 16 Oct
// 23:33" later. An ask's expiry was said as a clock time alone, so an ask lasting a week read as one ending
// tonight (issue 373).
func by(t, now time.Time, loc *time.Location) string {
lt, ln := t.In(loc), now.In(loc)
y1, m1, d1 := lt.Date()
y2, m2, d2 := ln.Date()
if y1 == y2 && m1 == m2 && d1 == d2 {
return lt.Format("15:04")
}
ty, tm, td := ln.AddDate(0, 0, 1).Date()
if y1 == ty && m1 == tm && d1 == td {
return "tomorrow " + lt.Format("15:04")
}
return lt.Format("Mon 2 Jan 15:04")
}
func capital(s string) string {
if s == "" {
return s
@@ -225,6 +225,8 @@ func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *te
t.Fatal(err)
}
h.Condition(EventCleared, cleared)
c.pass(ClearSettle)
h.Tick()
end := dt.edits["g14=1"]
if end.Title != e.cleared || end.Body != "" || len(end.Actions) != 0 {
t.Errorf("clearance %q / %q / %v, want one line %q", end.Title, end.Body, end.Actions, e.cleared)
+4
View File
@@ -8,6 +8,10 @@ as the operator.
- **Shows what the router sends** (its kind's worker on the `channel` queue): a message, a question with
buttons — each button carries only the router's one-time ticket — or an edit of a message shown before.
It sends to the account the router names as linked, or, before one is, to the chat a `/start` came from.
A message sent under a handle already shown (a reminder, an escalation) **replaces** it (issue 373): the
new message is sent, ringing as its kind rings (an edit here notifies nobody), and then the earlier one is
deleted; one the service will not delete loses its buttons and says a newer message follows. A send that
fails leaves the earlier message.
- **Says what the operator chose** on `intake` under its own kind: a tapped button (`choice`), with the
sender as Telegram authenticated it; `/start` (`link`); a message it could not deliver (`failed`); and
whether it can send (`standing`). Only a private chat with the account itself counts.
+31 -3
View File
@@ -10,7 +10,9 @@ package main
// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). A warning or a
// clearing arrives without sound, an urgent message or a question rings (D4). A rate limit is waited out
// as told (D5); a refusal is a permanent failure, said to the router, never retried; an edit to the same
// text is a success (D6). A message that cannot be delivered after three tries is said as failed.
// text is a success (D6). A message that cannot be delivered after three tries is said as failed. A
// `send` or `show` under a handle already shown replaces that message: the new one sent, then the earlier
// one deleted.
// - **What arrives** is long-polled: outbound only. A tapped button is said as a `choice` with the sender
// the service authenticated, and acknowledged on the phone with the router's word on it — what was
// recorded, or why it was not — which comes as a reply to the tap; without one in TapWait, with words that
@@ -231,11 +233,21 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
}
words, buttons := text(m), c.buttons(m)
silent := m.Silent || (!m.Urgent && len(buttons) == 0)
var place *Placed
if verb == asks.Edit {
// **A message said again under a handle already shown replaces it** (issue 373): a reminder or an
// escalation is one message per problem, not one more beside it. An edit here notifies nobody, so this one
// is sent, ringing as its kind rings, and only then is the earlier one deleted — one the service will not
// delete loses its buttons and says a newer message follows. A send that fails leaves the earlier one.
var place, earlier *Placed
switch {
case verb == asks.Edit:
if place, err = c.Store.Placed(m.Handle); err != nil {
return err // the state is away: offered again
}
case !m.Secret:
if earlier, err = c.Store.Placed(m.Handle); err != nil {
c.Logf("[telegram] where an earlier message under this handle is could not be read (%v): sent as a new one", err)
earlier = nil
}
}
for try := 1; ; try++ {
var id int64
@@ -252,6 +264,9 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
err = c.newMessage(m.Handle, chat, words, buttons, silent, &id)
}
if err == nil {
if earlier != nil {
c.replaced(*earlier)
}
c.mu.Lock()
c.lastSent, c.lastErr = c.Now(), ""
c.delivered++
@@ -274,6 +289,19 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
}
}
// replaced takes an earlier message out of the chat: deleted, or where the service refuses that (a message
// older than two days), its buttons taken away and the newer message named.
func (c *Channel) replaced(p Placed) {
err := c.Bot.Delete(p.Chat, p.ID)
if err == nil {
return
}
c.Logf("[telegram] an earlier message could not be deleted (%v); its buttons are taken away instead", err)
if err := c.Bot.Edit(p.Chat, p.ID, "Replaced by a newer message below.", nil); err != nil && !gone(err) {
c.Logf("[telegram] an earlier message could not be marked replaced either: %v", err)
}
}
func (c *Channel) newMessage(handle string, chat int64, words string, buttons [][2]string, silent bool, id *int64) error {
got, err := c.Bot.Send(chat, words, buttons, silent)
if err != nil {
@@ -580,3 +580,63 @@ func TestAToastIsCutAtTelegramsBound(t *testing.T) {
t.Errorf("toast %q", got)
}
}
// Issue 373: a reminder or an escalation is sent under the handle of the message it follows, and replaces it:
// the earlier one leaves the chat and the new one rings as its kind rings. One the service will not delete
// loses its buttons. A new handle is a new message, and nothing is deleted.
func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
c, f, _, st := newChannel(t, goodToken)
subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Urgent: ace silent", Urgent: true, To: "42",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "0123456789abcdef0123456789abcdef"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
first := st.placed["m1"].ID
subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "another problem", To: "42"})
_ = c.Take(subject, body)
if len(f.of("deleteMessage")) != 0 {
t.Fatal("a new handle deleted a message")
}
subject, body = work(asks.Show, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "fedcba9876543210fedcba9876543210"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
del := f.of("deleteMessage")
if len(del) != 1 || del[0].Body["message_id"] != float64(first) {
t.Fatalf("the earlier message was not deleted: %+v", del)
}
sent := f.of("sendMessage")
if len(sent) != 3 || sent[2].Body["disable_notification"] != false || st.placed["m1"].ID == first {
t.Fatalf("the reminder was not sent ringing in its place: %d sends, placed %d", len(sent), st.placed["m1"].ID)
}
// The service refuses the delete: the earlier message loses its buttons instead.
f.answer["deleteMessage"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message can't be deleted"}`
}
second := st.placed["m1"].ID
subject, body = work(asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: ace silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
ed := f.of("editMessageText")
if len(ed) != 1 || ed[0].Body["message_id"] != float64(second) || !strings.Contains(ed[0].Body["text"].(string), "Replaced") {
t.Fatalf("an undeletable earlier message kept its buttons: %+v", ed)
}
if kb, _ := json.Marshal(ed[0].Body["reply_markup"]); string(kb) != `{"inline_keyboard":[]}` {
t.Errorf("its buttons stayed: %s", kb)
}
}
// The review of PR 176: the new message is sent before the earlier one is deleted, so a send that fails leaves
// the operator the earlier message rather than none.
func TestAReplacementThatCannotBeSentLeavesTheEarlierMessage(t *testing.T) {
c, f, _, st := newChannel(t, goodToken)
st.Place("m1", Placed{Chat: 42, ID: 7})
f.answer["sendMessage"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
}
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
if len(f.of("deleteMessage")) != 0 || st.placed["m1"].ID != 7 {
t.Fatalf("the earlier message was taken away though its replacement was not sent: %+v", f.of("deleteMessage"))
}
}