Say each notification in plain words the operator reads at a glance
mesh/merge-gate pass: builds messenger → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/plain-notifications ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds messenger → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/plain-notifications ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
The desktop popups were walls of plan ids, commits, keys and verb syntax in UTC. The messenger now shows the condition's headline, explanation and resolved line, since when in the operator's time zone, and a clearance in one line (hq ADR 0253).
This commit is contained in:
@@ -35,6 +35,15 @@ what is said is decided by the state now and by when a thing happened, never by
|
||||
- **Start grace.** Nothing is sent in the **first minute** after start but that one urgent message;
|
||||
what arrives meanwhile goes out, coalesced, when the minute ends.
|
||||
|
||||
**A message reads in one glance** (novox/hq ADR 0253). Its title is the condition's headline — a few
|
||||
plain words such as "openrazer not working on g14" — prefixed "Urgent:", "Still open:" or "Now urgent:"
|
||||
where that applies; its body is the condition's explanation (what happened, what it means, whether the
|
||||
operator needs to act) and since when, in the operator's time zone. No key, id, commit, command or markup:
|
||||
those stay in the controller's `conditions` and in this module's history. A clearance is one line, the
|
||||
condition's resolved line and how long it was open: "Resolved: openrazer works again on g14, after 26 min".
|
||||
A condition from a controller older than plain words, or a module's notice without a headline, is said from
|
||||
its summary, with code spans taken out (`cmd/messenger/words.go`).
|
||||
|
||||
| When | What |
|
||||
|---|---|
|
||||
| `condition-raised` | one message, deduplicated by the condition's key |
|
||||
@@ -76,6 +85,9 @@ Nothing is sent until these are given; `messenger_status` says which is missing.
|
||||
2. **The chat id**, as a setting: `settings` for `messenger` with `{"telegram-chat-id": "<id>"}`.
|
||||
3. **The desktop machines**, as a setting: `{"desktop-machines": ["<the machine the operator sits at>", …]}`.
|
||||
Without it, warnings go to Telegram.
|
||||
4. **The time zone**, as a setting: `{"time-zone": "<an IANA zone name>"}`. Every time a message says is
|
||||
said in it; not given, the zone of the machine this runs on. A name this machine does not know is
|
||||
logged and that zone used instead.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -83,7 +95,7 @@ Nothing is sent until these are given; `messenger_status` says which is missing.
|
||||
|---|---|
|
||||
| `operator-channel.open` | what is open now, urgent first, with where it went, silenced, reminded, held, refused, unsent |
|
||||
| `operator-channel.history` | what was said lately, and the refusals |
|
||||
| `operator-channel.notify` | a message from a module using the seat: key, severity, summary; `clear` to end it |
|
||||
| `operator-channel.notify` | a message from a module using the seat: key, severity, summary, and best a plain `headline`, `explanation` and `resolved` line; `clear` to end it |
|
||||
| `messenger_status` | whether the operator can be reached and why not; `check` asks Telegram whether the token works |
|
||||
| `messenger_recent` | the recent sends, edits, digests and failures |
|
||||
| `messenger_test` | a test message now, to telegram, desktop or both |
|
||||
|
||||
@@ -17,6 +17,8 @@ package main
|
||||
// - `severity` is `urgent` or `warning`. Anything else is not guessed: the event is unreadable.
|
||||
// - `silenced` is absent, null, or an object {until, by, why}; `until` in the past is not silenced.
|
||||
// - Times are RFC 3339.
|
||||
// - `headline`, `explanation` and `resolved` are the plain words the operator reads (novox/hq ADR
|
||||
// 0253): a condition from a controller older than them has none, and is said from its summary.
|
||||
// - `key` is `<scope>.<id>.<kind>`. When it is absent it is made from subject and kind; when
|
||||
// neither gives one the event is unreadable.
|
||||
// - A cleared event carries the condition as last held, and may add `cleared` (its time).
|
||||
@@ -60,6 +62,9 @@ type Condition struct {
|
||||
Machine string
|
||||
Severity string
|
||||
Summary string
|
||||
Headline string
|
||||
Explanation string
|
||||
Resolved string
|
||||
Source string
|
||||
Resolver string
|
||||
Raised time.Time
|
||||
@@ -164,6 +169,9 @@ func DecodeCondition(event string, body []byte) (Condition, error) {
|
||||
c.Kind = str("kind")
|
||||
c.Severity = str("severity")
|
||||
c.Summary = str("summary")
|
||||
c.Headline = str("headline")
|
||||
c.Explanation = str("explanation")
|
||||
c.Resolved = str("resolved")
|
||||
c.Resolver = str("resolver")
|
||||
c.Raised = when("raised")
|
||||
c.LastObserved = when("last-observed", "last_observed", "lastObserved")
|
||||
|
||||
@@ -205,7 +205,7 @@ func TestTodaysBacklogWithAnUrgentStillOpenIsOneMessage(t *testing.T) {
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 || len(tg.edits)+len(dt.edits) != 0 {
|
||||
t.Fatalf("telegram %d desktop %d", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
if !strings.HasPrefix(tg.sends[0].Title, "URGENT: ") || !strings.Contains(tg.sends[0].Text(), "merge.mesh-catalog.f2b51686.not-acted") {
|
||||
if !strings.HasPrefix(tg.sends[0].Title, "Urgent: ") || !strings.Contains(tg.sends[0].Text(), "Merge not acted") {
|
||||
t.Fatalf("message: %q", tg.sends[0].Text())
|
||||
}
|
||||
if got := len(h.Open()); got != 4 {
|
||||
@@ -280,11 +280,11 @@ func TestABurstIsOneMessage(t *testing.T) {
|
||||
t.Fatalf("sent inside the window: %d", len(tg.sends))
|
||||
}
|
||||
runFor(h, c, time.Minute)
|
||||
if len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "WARNING: 5 new warnings") {
|
||||
if len(tg.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Mesh: 5 new problems") {
|
||||
t.Fatalf("sends %d: %+v", len(tg.sends), tg.sends)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if !strings.Contains(tg.sends[0].Body, fmt.Sprintf("seat.s%d.silent", i)) {
|
||||
if !strings.Contains(tg.sends[0].Body, fmt.Sprintf("seat s%d silent", i)) {
|
||||
t.Fatalf("the digest does not name s%d: %q", i, tg.sends[0].Body)
|
||||
}
|
||||
}
|
||||
@@ -293,7 +293,7 @@ func TestABurstIsOneMessage(t *testing.T) {
|
||||
h.Condition(EventCleared, Condition{Key: fmt.Sprintf("seat.s%d.silent", i)})
|
||||
}
|
||||
runFor(h, c, time.Minute)
|
||||
if len(tg.sends) != 2 || len(tg.edits) != 0 || !strings.HasPrefix(tg.sends[1].Title, "CLEARED: 3 cleared") {
|
||||
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)
|
||||
}
|
||||
// A burst of urgent ones: the first at once, the rest as one.
|
||||
@@ -303,7 +303,7 @@ func TestABurstIsOneMessage(t *testing.T) {
|
||||
c.pass(time.Second)
|
||||
}
|
||||
runFor(h, c, time.Minute)
|
||||
if len(tg.sends) != 4 || !strings.HasPrefix(tg.sends[3].Title, "URGENT: 5 new urgent") {
|
||||
if len(tg.sends) != 4 || !strings.HasPrefix(tg.sends[3].Title, "Urgent: 5 urgent problems") {
|
||||
t.Fatalf("sends %d: %q", len(tg.sends), tg.sends[len(tg.sends)-1].Title)
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func TestTheDesktopIsGentle(t *testing.T) {
|
||||
// Urgent is not held to the desktop's rhythm.
|
||||
h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "the home server is silent"))
|
||||
h.Flush()
|
||||
if len(dt.sends) != 2 || !strings.HasPrefix(dt.sends[1].Title, "URGENT: ") || len(tg.sends) != 1 {
|
||||
if len(dt.sends) != 2 || !strings.HasPrefix(dt.sends[1].Title, "Urgent: ") || len(tg.sends) != 1 {
|
||||
t.Fatalf("urgent: desktop %d telegram %d", len(dt.sends), len(tg.sends))
|
||||
}
|
||||
// The urgent message took the held warnings along — one notification, read in one go. What
|
||||
@@ -358,7 +358,7 @@ func TestNothingIsSentInTheFirstMinuteButTheUrgentOpenAsOne(t *testing.T) {
|
||||
if len(res.Said) != 2 || len(res.State) != 1 || len(res.News) != 1 {
|
||||
t.Fatalf("sync: %+v", res)
|
||||
}
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "URGENT: 2 new urgent") {
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 || !strings.HasPrefix(tg.sends[0].Title, "Urgent: 2 urgent problems") {
|
||||
t.Fatalf("telegram %d desktop %d: %+v", len(tg.sends), len(dt.sends), tg.sends)
|
||||
}
|
||||
c.pass(10 * time.Second)
|
||||
@@ -377,8 +377,8 @@ func TestNothingIsSentInTheFirstMinuteButTheUrgentOpenAsOne(t *testing.T) {
|
||||
if len(dt.sends) != 2 || len(tg.sends) != 2 {
|
||||
t.Fatalf("after the grace: telegram %d desktop %d", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
if d := dt.sends[1]; !strings.Contains(d.Body, "plan.42.stalled") || !strings.Contains(d.Body, "plan.43.stalled") ||
|
||||
!strings.Contains(d.Body, "machine.shanks.silent") || strings.Contains(d.Body, "plan.41.stalled") {
|
||||
if d := dt.sends[1]; !strings.Contains(d.Body, "plan 42 stalled") || !strings.Contains(d.Body, "plan 43 stalled") ||
|
||||
!strings.Contains(d.Body, "machine shanks silent") || strings.Contains(d.Body, "plan 41 stalled") {
|
||||
t.Fatalf("desktop digest: %q", d.Text())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,11 +115,16 @@ func editsSilently(ch Channel) bool {
|
||||
|
||||
// Record is one open message, kept in the module's own state so a restart forgets nothing.
|
||||
type Record struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
Subject string `json:"subject"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
Subject string `json:"subject"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
// Headline, Explanation and Resolved are the plain words the operator reads (novox/hq ADR 0253):
|
||||
// the controller's, or a module's through notify; empty, the message is said from the summary.
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
Resolved string `json:"resolved,omitempty"`
|
||||
Origin string `json:"origin"`
|
||||
More string `json:"more"`
|
||||
Raised time.Time `json:"raised"`
|
||||
@@ -138,6 +143,18 @@ type Record struct {
|
||||
Seen time.Time `json:"seen,omitempty"`
|
||||
}
|
||||
|
||||
// recordOf is a condition as this holder keeps it.
|
||||
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, Origin: "condition",
|
||||
More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill}
|
||||
}
|
||||
|
||||
// takeWords keeps the plain words current, as the summary is: a condition's words follow what is seen.
|
||||
func (r *Record) takeWords(from Record) {
|
||||
r.Headline, r.Explanation, r.Resolved = from.Headline, from.Explanation, from.Resolved
|
||||
}
|
||||
|
||||
func (r *Record) silenced(now time.Time) bool {
|
||||
return !r.SilencedTill.IsZero() && now.Before(r.SilencedTill)
|
||||
}
|
||||
@@ -179,6 +196,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
|
||||
// Zone is the operator's time zone, which every time in a message is said in; nil is this machine's.
|
||||
Zone func() *time.Location
|
||||
|
||||
work sync.Mutex // one event, call or tick at a time
|
||||
mu sync.Mutex // what the status reads
|
||||
@@ -289,10 +308,7 @@ func (h *Holder) Condition(event string, c Condition) {
|
||||
h.lastHeard = now
|
||||
synced := h.syncedAt
|
||||
h.mu.Unlock()
|
||||
rec := Record{
|
||||
Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary,
|
||||
Origin: "condition", More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill,
|
||||
}
|
||||
rec := recordOf(c)
|
||||
when := c.When(event)
|
||||
quiet := false
|
||||
switch {
|
||||
@@ -339,9 +355,9 @@ func (h *Holder) Unreadable(key string, err error) {
|
||||
h.mu.Unlock()
|
||||
h.Logf("[messenger] refused %s: %v (unreadable events since start: %d)", key, err, n)
|
||||
h.sayOnce("messenger.unreadable-event", time.Hour, Message{
|
||||
Title: "WARNING: a condition event could not be read",
|
||||
Body: fmt.Sprintf("the operator-channel's holder could not read %d condition event(s) from the controller; "+
|
||||
"what was wrong is in messenger_status. A condition may be open that was not said.", n),
|
||||
Title: "A mesh message could not be read",
|
||||
Body: fmt.Sprintf("The messenger could not read %d update(s) from the controller, so a problem may be open "+
|
||||
"that was not said. An agent can find out why in the messenger's status.", n),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -354,6 +370,7 @@ func (h *Holder) raised(rec Record, quiet bool) {
|
||||
// Said again while open: the same message. Its words are kept current; nothing is sent.
|
||||
h.mu.Lock()
|
||||
old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill
|
||||
old.takeWords(rec)
|
||||
if rec.Severity != "" {
|
||||
old.Severity = rec.Severity
|
||||
}
|
||||
@@ -368,6 +385,7 @@ func (h *Holder) raised(rec Record, quiet bool) {
|
||||
old.Cleared = time.Time{}
|
||||
old.Count++
|
||||
old.Summary, old.Severity, old.SilencedTill = rec.Summary, rec.Severity, rec.SilencedTill
|
||||
old.takeWords(rec)
|
||||
old.Seen = now
|
||||
h.mu.Unlock()
|
||||
if !quiet && !old.silenced(now) && old.told() {
|
||||
@@ -411,6 +429,7 @@ func (h *Holder) changed(rec Record, quiet bool) {
|
||||
h.mu.Lock()
|
||||
escalated := old.Severity == Warning && rec.Severity == Urgent
|
||||
old.Summary, old.Subject, old.SilencedTill = rec.Summary, rec.Subject, rec.SilencedTill
|
||||
old.takeWords(rec)
|
||||
if rec.Severity != "" {
|
||||
old.Severity = rec.Severity
|
||||
}
|
||||
@@ -521,52 +540,6 @@ func remindAfter(severity string) time.Duration {
|
||||
return RemindWarning
|
||||
}
|
||||
|
||||
// compose is the message for a record. withhold names what the content rule refused, so the words
|
||||
// that carried it are not sent.
|
||||
func compose(r *Record, what string, now time.Time, withhold int) Message {
|
||||
sev := strings.ToUpper(r.Severity)
|
||||
if sev == "" {
|
||||
sev = "WARNING"
|
||||
}
|
||||
summary := r.Summary
|
||||
if withhold > 0 {
|
||||
summary = "channel-refused: this message carried " + r.Refused + ", so its words are withheld"
|
||||
}
|
||||
var title string
|
||||
switch what {
|
||||
case "raised":
|
||||
title = sev + ": " + summary
|
||||
case "reminder":
|
||||
title = "STILL OPEN after " + roughly(now.Sub(r.Raised)) + ": " + summary
|
||||
case "escalated":
|
||||
title = "NOW URGENT: " + summary
|
||||
case "reopened":
|
||||
title = sev + " (open again, " + fmt.Sprint(r.Count) + " times): " + summary
|
||||
case "cleared":
|
||||
title = "CLEARED after " + roughly(r.Cleared.Sub(r.Raised)) + ": " + summary
|
||||
default:
|
||||
title = sev + ": " + summary
|
||||
}
|
||||
lines := []string{}
|
||||
if withhold < 3 && r.Subject != "" {
|
||||
about := "about: " + r.Subject
|
||||
if r.Kind != "" {
|
||||
about += " (" + r.Kind + ")"
|
||||
}
|
||||
lines = append(lines, about)
|
||||
}
|
||||
lines = append(lines, "since: "+r.Raised.UTC().Format("2006-01-02 15:04")+" UTC")
|
||||
if withhold < 2 {
|
||||
lines = append(lines, "key: "+r.Key)
|
||||
if r.More != "" {
|
||||
lines = append(lines, "more: "+r.More)
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, "more: conditions (the open ones, through the mesh)")
|
||||
}
|
||||
return Message{Title: title, Body: strings.Join(lines, "\n"), Urgent: r.Severity == Urgent, Quiet: what == "cleared"}
|
||||
}
|
||||
|
||||
// say composes a record's message under the content rule: refused, it is composed again with less of
|
||||
// it, until what remains may leave. The refusal is recorded and stated once per record.
|
||||
func (h *Holder) say(r *Record, what string) Message {
|
||||
@@ -574,13 +547,13 @@ func (h *Holder) say(r *Record, what string) Message {
|
||||
if r.Refused != "" {
|
||||
// Refused before: its words stay withheld in every later message too.
|
||||
for w := 1; w <= 3; w++ {
|
||||
m := compose(r, what, now, w)
|
||||
m := compose(r, what, now, h.zone(), w)
|
||||
if _, ok := Check(m.Text()); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
m := compose(r, what, now, 0)
|
||||
m := compose(r, what, now, h.zone(), 0)
|
||||
refusal, ok := Check(m.Text())
|
||||
if ok {
|
||||
return m
|
||||
@@ -605,13 +578,12 @@ func (h *Holder) say(r *Record, what string) Message {
|
||||
}
|
||||
}
|
||||
for w := 1; w <= 3; w++ {
|
||||
m := compose(r, what, now, w)
|
||||
m := compose(r, what, now, h.zone(), w)
|
||||
if _, ok := Check(m.Text()); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return Message{Title: "WARNING: channel-refused: a message carried " + refusal.What + " and was withheld",
|
||||
Body: "more: conditions (the open ones, through the mesh)", Urgent: r.Severity == Urgent}
|
||||
return withheld(refusal.What, r.Severity == Urgent)
|
||||
}
|
||||
|
||||
// deliver sends a record's message where its severity routes it.
|
||||
|
||||
@@ -72,7 +72,24 @@ func (c *clock) pass(d time.Duration) { c.t = c.t.Add(d) }
|
||||
func start() *clock { return &clock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)} }
|
||||
func cond(key, sev, summary string) Condition {
|
||||
parts := strings.Split(key, ".")
|
||||
return Condition{Key: key, Scope: parts[0], ID: parts[1], Kind: parts[len(parts)-1], Severity: sev, Summary: summary}
|
||||
return Condition{Key: key, Scope: parts[0], ID: parts[1], Kind: parts[len(parts)-1], Severity: sev, Summary: summary,
|
||||
Headline: headlineOf(key), Explanation: "What it means: " + summary + "."}
|
||||
}
|
||||
|
||||
// headlineOf is the headline a test condition carries: its key in words, so a message can be told by it
|
||||
// and never carries the key itself (novox/hq ADR 0253).
|
||||
func headlineOf(key string) string { return strings.ReplaceAll(key, ".", " ") }
|
||||
|
||||
// keyless fails a message that carries a condition's key: the operator reads words, never keys.
|
||||
func keyless(t *testing.T, ms ...Message) {
|
||||
t.Helper()
|
||||
for _, m := range ms {
|
||||
for _, w := range strings.Fields(m.Text()) {
|
||||
if strings.Count(w, ".") >= 2 && !strings.HasSuffix(w, ".") {
|
||||
t.Errorf("a message carries a key %q: %q", w, m.Text())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settle lets a burst window pass and sends what is due.
|
||||
@@ -104,7 +121,9 @@ func TestARaisedConditionIsSentOnceByItsKey(t *testing.T) {
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 {
|
||||
t.Fatalf("urgent: telegram %d, desktop %d; want one each", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
if !strings.Contains(tg.sends[0].Text(), "machine.ace.silent") || !strings.HasPrefix(tg.sends[0].Title, "URGENT: ") {
|
||||
keyless(t, tg.sends...)
|
||||
if tg.sends[0].Title != "Urgent: machine ace silent" || !strings.HasPrefix(tg.sends[0].Body, "What it means: ") ||
|
||||
!strings.Contains(tg.sends[0].Body, "\nSince ") {
|
||||
t.Fatalf("message: %q", tg.sends[0].Text())
|
||||
}
|
||||
if open := h.Open(); len(open) != 1 || open[0].Count != 1 {
|
||||
@@ -151,7 +170,7 @@ func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) {
|
||||
if len(tg.sends) != CapPerHour+1 {
|
||||
t.Fatalf("sent %d, want the cap of %d and one saying so", len(tg.sends), CapPerHour)
|
||||
}
|
||||
if last := tg.sends[CapPerHour]; !strings.HasPrefix(last.Title, "HELD BACK: telegram is at its cap") {
|
||||
if last := tg.sends[CapPerHour]; !strings.HasPrefix(last.Title, "Mesh messages paused for this hour") {
|
||||
t.Fatalf("the cap was not said: %q", last.Title)
|
||||
}
|
||||
st := h.Status("listening")
|
||||
@@ -172,11 +191,11 @@ func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) {
|
||||
t.Fatalf("sends %d", len(tg.sends))
|
||||
}
|
||||
d := tg.sends[len(tg.sends)-1]
|
||||
if !strings.HasPrefix(d.Title, "URGENT: 5 new urgent") {
|
||||
if !strings.HasPrefix(d.Title, "Urgent: 5 urgent problems") {
|
||||
t.Fatalf("digest: %q", d.Title)
|
||||
}
|
||||
for i := 20; i < 25; i++ {
|
||||
if !strings.Contains(d.Body, fmt.Sprintf("machine.m%d.silent", i)) {
|
||||
if !strings.Contains(d.Body, fmt.Sprintf("machine m%d silent", i)) {
|
||||
t.Fatalf("the digest does not name m%d: %q", i, d.Body)
|
||||
}
|
||||
}
|
||||
@@ -194,7 +213,7 @@ func TestAMessageCarryingAnAddressIsRefusedAndSaidWithItsWordsWithheld(t *testin
|
||||
if strings.Contains(text, "192.168") || strings.Contains(text, "token=") {
|
||||
t.Fatalf("the address left: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "channel-refused") || !strings.Contains(text, "provider.keycloak.ace.failing") {
|
||||
if !strings.Contains(text, "withheld, because it carried an IPv4 address") || !strings.Contains(text, "provider keycloak ace failing") {
|
||||
t.Fatalf("not said as refused, by key: %q", text)
|
||||
}
|
||||
hist := h.History(10)["refusals"].([]RefusalNote)
|
||||
@@ -242,7 +261,7 @@ func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) {
|
||||
}
|
||||
c.pass(2 * time.Minute)
|
||||
h.Tick()
|
||||
if len(tg.sends) != 3 || !strings.HasPrefix(tg.sends[2].Title, "STILL OPEN after 61 min") {
|
||||
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)
|
||||
}
|
||||
c.pass(3 * time.Hour)
|
||||
@@ -253,7 +272,7 @@ func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) {
|
||||
c.pass(9 * time.Hour) // the warning is now 13 h old
|
||||
h.Tick()
|
||||
settle(h, c)
|
||||
if len(tg.sends) != 4 || !strings.Contains(tg.sends[3].Text(), "plan.41.stalled") {
|
||||
if len(tg.sends) != 4 || tg.sends[3].Title != "Still open: plan 41 stalled" {
|
||||
t.Fatalf("warning reminder: %d", len(tg.sends))
|
||||
}
|
||||
}
|
||||
@@ -268,7 +287,7 @@ func TestClearedEditsTheFirstMessageAndReopenedWithinTenMinutesIsNotNew(t *testi
|
||||
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))
|
||||
}
|
||||
if m := tg.edits["1"]; !strings.HasPrefix(m.Title, "CLEARED after 14 min") {
|
||||
if m := tg.edits["1"]; m.Title != "Resolved: machine ace silent, after 14 min" || m.Body != "" {
|
||||
t.Fatalf("edit: %q", m.Title)
|
||||
}
|
||||
if len(h.Open()) != 0 {
|
||||
@@ -277,7 +296,7 @@ func TestClearedEditsTheFirstMessageAndReopenedWithinTenMinutesIsNotNew(t *testi
|
||||
c.pass(5 * 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, "open again, 2 times") {
|
||||
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)
|
||||
}
|
||||
h.Condition(EventCleared, Condition{Key: k.Key})
|
||||
@@ -326,7 +345,7 @@ 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") {
|
||||
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))
|
||||
}
|
||||
// Escalated while the warning still waited its window: one message, the urgent one.
|
||||
@@ -335,7 +354,7 @@ 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) != 3 || !strings.HasPrefix(dt.sends[2].Title, "Now urgent: ") {
|
||||
t.Fatalf("desktop %d: %q", len(dt.sends), dt.sends[len(dt.sends)-1].Title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
type Settings struct {
|
||||
TelegramChatID string `json:"telegram-chat-id"`
|
||||
DesktopMachines []string `json:"desktop-machines"`
|
||||
// TimeZone is the operator's, an IANA name such as Europe/Brussels: every time a message says is said
|
||||
// in it. Empty is this machine's own zone.
|
||||
TimeZone string `json:"time-zone"`
|
||||
}
|
||||
|
||||
// zoneOf is the location a time-zone setting names, or this machine's own when it names none or one
|
||||
// that does not exist (said, never guessed silently).
|
||||
func zoneOf(name string) (*time.Location, error) {
|
||||
if name == "" {
|
||||
return time.Local, nil
|
||||
}
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
return time.Local, fmt.Errorf("the time-zone %q is not one this machine knows; times are said in its own zone", name)
|
||||
}
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
func readSettings(path string) (Settings, error) {
|
||||
@@ -47,6 +63,9 @@ func readSettings(path string) (Settings, error) {
|
||||
case float64:
|
||||
s.TelegramChatID = fmt.Sprintf("%.0f", v)
|
||||
}
|
||||
if tz, ok := loose["time-zone"].(string); ok {
|
||||
s.TimeZone = strings.TrimSpace(tz)
|
||||
}
|
||||
if list, ok := loose["desktop-machines"].([]any); ok {
|
||||
for _, m := range list {
|
||||
if name, ok := m.(string); ok && strings.TrimSpace(name) != "" {
|
||||
@@ -128,7 +147,7 @@ func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.n
|
||||
func main() {
|
||||
settingsFile := os.Getenv("MESH_MESSENGER_SETTINGS")
|
||||
var said sync.Mutex
|
||||
lastSaid := ""
|
||||
lastSaid, zoneSaid := "", ""
|
||||
settings := func() Settings {
|
||||
s, err := readSettings(settingsFile)
|
||||
said.Lock()
|
||||
@@ -153,7 +172,20 @@ func main() {
|
||||
},
|
||||
Store: stateStore{},
|
||||
Logf: logf,
|
||||
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
|
||||
Zone: func() *time.Location {
|
||||
loc, err := zoneOf(settings().TimeZone)
|
||||
said.Lock()
|
||||
defer said.Unlock()
|
||||
if err != nil && err.Error() != zoneSaid {
|
||||
logf("[messenger] %v", err)
|
||||
}
|
||||
zoneSaid = ""
|
||||
if err != nil {
|
||||
zoneSaid = err.Error()
|
||||
}
|
||||
return loc
|
||||
},
|
||||
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
|
||||
}
|
||||
h.init()
|
||||
l := &listening{now: "not yet: starting"}
|
||||
@@ -286,7 +318,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 50)), nil }},
|
||||
{Name: "operator-channel.notify",
|
||||
Description: "Tell the operator something, as a module that uses the seat: a key (the same key is the same " +
|
||||
"message), urgent or warning, one line in the mesh's words, and what it is about. clear says it is over. " +
|
||||
"message), urgent or warning, one line in the mesh's words, and what it is about; and best, in plain " +
|
||||
"words, a headline, an explanation and the line said when it is over. clear says it is over. " +
|
||||
"Roles and words only: an address, a path or a secret is refused. Deduplicated, capped and routed " +
|
||||
"like the controller's conditions.",
|
||||
Input: map[string]any{
|
||||
@@ -294,11 +327,16 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
"severity": map[string]any{"type": "string", "enum": []string{Urgent, Warning}},
|
||||
"summary": str("one line in the mesh's words"),
|
||||
"subject": str("what it is about: a machine's role, a module, a plan"),
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. backup failed on ace: no ids, keys or commands"),
|
||||
"explanation": str("one or two plain sentences: what happened, what it means for the operator, and whether " +
|
||||
"they need to act"),
|
||||
"resolved": str("the one short line said when it is over, e.g. backups run again on ace"),
|
||||
"clear": map[string]any{"type": "boolean", "description": "it is over: the message is edited to say so"},
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
clear, _ := a["clear"].(bool)
|
||||
return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear)
|
||||
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: "messenger_status",
|
||||
Description: "Whether the operator can be reached, and why not: each channel — can it send, what it lacks " +
|
||||
|
||||
@@ -265,9 +265,9 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
|
||||
said := h.capSaid[name]
|
||||
h.mu.Unlock()
|
||||
if said.IsZero() || now.Sub(said) >= time.Hour {
|
||||
m := Message{Title: fmt.Sprintf("HELD BACK: %s is at its cap of %d messages an hour", name, CapPerHour),
|
||||
Body: fmt.Sprintf("%d message(s) are held and go out as one when the hour allows.\n"+
|
||||
"more: conditions (through the mesh)", len(entries)), Urgent: true}
|
||||
m := Message{Title: "Mesh messages paused for this hour",
|
||||
Body: fmt.Sprintf("The mesh sent %d messages here this hour, its limit. %d more are held and come as "+
|
||||
"one message when the hour allows.", CapPerHour, len(entries)), Urgent: true}
|
||||
_, err := ch.Send(m)
|
||||
h.noteChannel(name, err)
|
||||
if err == nil {
|
||||
@@ -414,32 +414,24 @@ func digest(items []item, msgs []Message) Message {
|
||||
parts = append(parts, fmt.Sprintf("%d %s", n, many))
|
||||
}
|
||||
}
|
||||
add(newU, "new urgent", "new urgent")
|
||||
add(newW, "new warning", "new warnings")
|
||||
add(newU, "urgent problem", "urgent problems")
|
||||
add(newW, "new problem", "new problems")
|
||||
add(still, "still open", "still open")
|
||||
add(cleared, "cleared", "cleared")
|
||||
add(cleared, "resolved", "resolved")
|
||||
add(notices, "notice", "notices")
|
||||
prefix := "WARNING"
|
||||
prefix := "Mesh"
|
||||
if urgent {
|
||||
prefix = "URGENT"
|
||||
} else if quiet {
|
||||
prefix = "CLEARED"
|
||||
prefix = "Urgent"
|
||||
}
|
||||
// Each line is one message's title: a headline, or a resolved line — plain words, no key (ADR 0253).
|
||||
var lines []string
|
||||
for i, m := range msgs {
|
||||
if i == DigestLines {
|
||||
lines = append(lines, fmt.Sprintf("and %d more", len(msgs)-DigestLines))
|
||||
break
|
||||
}
|
||||
line := m.Title
|
||||
if key := items[i].Key; items[i].Msg == nil {
|
||||
if _, ok := Check(key); ok {
|
||||
line += " [" + key + "]"
|
||||
}
|
||||
}
|
||||
lines = append(lines, "- "+line)
|
||||
lines = append(lines, "- "+m.Title)
|
||||
}
|
||||
lines = append(lines, "more: conditions (through the mesh)")
|
||||
return Message{Title: prefix + ": " + strings.Join(parts, ", "), Body: strings.Join(lines, "\n"), Urgent: urgent, Quiet: quiet}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ type Status struct {
|
||||
Stale int `json:"events_taken_as_state"` // older than the freshness bound: recorded, never said
|
||||
Grace bool `json:"in_start_grace,omitempty"`
|
||||
StateProblem string `json:"state_problem,omitempty"`
|
||||
TimeZone string `json:"time_zone"` // the zone every time in a message is said in
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
|
||||
@@ -95,7 +96,10 @@ func (h *Holder) Status(listening string) Status {
|
||||
h.mu.Unlock()
|
||||
sort.Strings(st.Unsent)
|
||||
st.Listening = listening
|
||||
st.TimeZone = h.zone().String()
|
||||
st.Rules = []string{
|
||||
"a message reads in one glance: the condition's headline as its title, its explanation, and since when in the operator's time zone; no key, id, commit or command — those stay in the controller's conditions and in this holder's history (novox/hq ADR 0253)",
|
||||
"a clearance is one line: the condition's resolved line and how long it was open",
|
||||
"history is never news: what is open is read from the controller on start, every 10 min and when an old event arrives; an event older than that reading is not acted on, one older than 10 min by its own time is state, never a message",
|
||||
"a clearing of something never said says nothing; an urgent condition open and never said is said, all of them in one message",
|
||||
"nothing is sent in the first minute after start but that one message",
|
||||
@@ -213,7 +217,7 @@ func (h *Holder) Test(channel, text string) map[string]string {
|
||||
out["refused"] = "it carried " + refusal.String() + "; nothing was sent"
|
||||
return out
|
||||
}
|
||||
m := Message{Title: "TEST: " + text, Body: "nothing is wrong; this was asked for"}
|
||||
m := Message{Title: "Test message: " + text, Body: "Nothing is wrong; this was asked for."}
|
||||
for _, ch := range h.channels() {
|
||||
if channel != "" && channel != "both" && channel != ch.Name() {
|
||||
continue
|
||||
@@ -240,7 +244,7 @@ func (h *Holder) Test(channel, text string) map[string]string {
|
||||
// Notify takes a message from a module that uses the seat (research 028 Q5, modules as sources):
|
||||
// the same shape, rule, cap and deduplication as a condition. Its key is put under `notify.` so it
|
||||
// can never be taken for one of the controller's conditions.
|
||||
func (h *Holder) Notify(key, severity, summary, subject string, clear bool) (map[string]any, error) {
|
||||
func (h *Holder) Notify(key, severity, summary, subject string, clear bool, plain ...string) (map[string]any, error) {
|
||||
if key == "" {
|
||||
return nil, errorf("key is required: the same key is the same message")
|
||||
}
|
||||
@@ -266,8 +270,20 @@ func (h *Holder) Notify(key, severity, summary, subject string, clear bool) (map
|
||||
if summary == "" {
|
||||
return nil, errorf("summary is required: one line in the mesh's words")
|
||||
}
|
||||
h.raised(Record{Key: key, Kind: "notice", Subject: subject, Severity: severity, Summary: summary,
|
||||
Origin: "notify", More: "operator-channel.open"}, false)
|
||||
rec := Record{Key: key, Kind: "notice", Subject: subject, Severity: severity, Summary: summary,
|
||||
Origin: "notify", More: "operator-channel.open"}
|
||||
// The plain words a module may give (novox/hq ADR 0253): a headline, an explanation, a resolved line.
|
||||
for i, w := range plain {
|
||||
switch i {
|
||||
case 0:
|
||||
rec.Headline = w
|
||||
case 1:
|
||||
rec.Explanation = w
|
||||
case 2:
|
||||
rec.Resolved = w
|
||||
}
|
||||
}
|
||||
h.raised(rec, false)
|
||||
h.mu.Lock()
|
||||
r := h.open[key]
|
||||
var sent []string
|
||||
|
||||
@@ -52,10 +52,7 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult {
|
||||
var urgent []*Record
|
||||
var fresh []*Record
|
||||
for _, c := range open {
|
||||
rec := Record{
|
||||
Key: c.Key, Kind: c.Kind, Subject: c.SubjectWords(), Severity: c.Severity, Summary: c.Summary,
|
||||
Origin: "condition", More: "conditions show " + c.Key, Raised: c.Raised, SilencedTill: c.SilencedTill,
|
||||
}
|
||||
rec := recordOf(c)
|
||||
h.mu.Lock()
|
||||
old := h.open[c.Key]
|
||||
var r *Record
|
||||
@@ -72,6 +69,7 @@ func (h *Holder) Sync(open []Condition, asked time.Time) SyncResult {
|
||||
old.Count++
|
||||
}
|
||||
old.Summary, old.Subject, old.Kind, old.SilencedTill = rec.Summary, rec.Subject, rec.Kind, rec.SilencedTill
|
||||
old.takeWords(rec)
|
||||
if rec.Severity != "" {
|
||||
old.Severity = rec.Severity
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
// What a message says (novox/hq ADR 0253): **it reads in one glance.** A title in plain words, one or two
|
||||
// plain sentences on what happened, what it means and whether to act, and when it began in the
|
||||
// operator's own time. No key, id, commit, verb or markup: the condition's key and summary stay in the
|
||||
// controller (`conditions`) and in this holder's history, for whoever looks closer.
|
||||
//
|
||||
// - The words are the condition's own: its headline, explanation and resolved line, made by the
|
||||
// controller where the condition is made, so every channel says the same thing. A condition from a
|
||||
// controller older than them, or a module's notice without a headline, is said from its summary,
|
||||
// with what the operator cannot read taken out (plainSummary).
|
||||
// - A clearance is one short line: "Resolved: openrazer works again on g14, after 26 min".
|
||||
// - A reminder, an escalation and a reopening say so in the title, before the same headline.
|
||||
// - Times are the operator's (the setting time-zone; this machine's zone when not given), and said as a
|
||||
// clock time today, a weekday this week, a date before that.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *Holder) zone() *time.Location {
|
||||
if h.Zone != nil {
|
||||
if loc := h.Zone(); loc != nil {
|
||||
return loc
|
||||
}
|
||||
}
|
||||
return time.Local
|
||||
}
|
||||
|
||||
// compose is the message for a record. withhold > 0 when the content rule refused what it said: 1 says
|
||||
// the headline alone, more says only that something was withheld.
|
||||
func compose(r *Record, what string, now time.Time, loc *time.Location, withhold int) Message {
|
||||
headline, explanation := wordsOf(r)
|
||||
urgent := r.Severity == Urgent
|
||||
if withhold >= 2 {
|
||||
return withheld(r.Refused, urgent)
|
||||
}
|
||||
if withhold == 1 {
|
||||
explanation = "Part of this message was withheld, because it carried " + r.Refused + "."
|
||||
}
|
||||
var title string
|
||||
switch what {
|
||||
case "cleared":
|
||||
return Message{Title: resolvedLine(r) + ", after " + roughly(r.Cleared.Sub(r.Raised)), Quiet: true}
|
||||
case "reminder":
|
||||
title = "Still open: " + headline
|
||||
case "escalated":
|
||||
title = "Now urgent: " + headline
|
||||
case "reopened":
|
||||
title = headline + " (again" + times(r.Count) + ")"
|
||||
if urgent {
|
||||
title = "Urgent: " + title
|
||||
}
|
||||
default:
|
||||
title = headline
|
||||
if urgent {
|
||||
title = "Urgent: " + headline
|
||||
}
|
||||
}
|
||||
since := "Since " + when(r.Raised, now, loc)
|
||||
if what == "reminder" {
|
||||
since = fmt.Sprintf("Open since %s, for %s.", when(r.Raised, now, loc), roughly(now.Sub(r.Raised)))
|
||||
} else {
|
||||
since += "."
|
||||
}
|
||||
body := since
|
||||
if explanation != "" {
|
||||
body = explanation + "\n" + since
|
||||
}
|
||||
return Message{Title: title, Body: body, Urgent: urgent}
|
||||
}
|
||||
|
||||
// withheld is what is sent in place of a message the content rule refused.
|
||||
func withheld(carried string, urgent bool) Message {
|
||||
return Message{Title: "A mesh message was withheld",
|
||||
Body: "It carried " + carried + ", which may not leave the mesh. An agent can read it among the mesh's " +
|
||||
"open conditions.", Urgent: urgent}
|
||||
}
|
||||
|
||||
// times is " 2nd time" for a count of 2, "" for 1.
|
||||
func times(n int) string {
|
||||
if n < 2 {
|
||||
return ""
|
||||
}
|
||||
suffix := "th"
|
||||
switch {
|
||||
case n%100 >= 11 && n%100 <= 13:
|
||||
case n%10 == 1:
|
||||
suffix = "st"
|
||||
case n%10 == 2:
|
||||
suffix = "nd"
|
||||
case n%10 == 3:
|
||||
suffix = "rd"
|
||||
}
|
||||
return fmt.Sprintf(", %d%s time", n, suffix)
|
||||
}
|
||||
|
||||
// wordsOf is a record's headline and explanation: its own, or made from its summary.
|
||||
func wordsOf(r *Record) (string, string) {
|
||||
if r.Headline != "" {
|
||||
return r.Headline, r.Explanation
|
||||
}
|
||||
summary := plainSummary(r.Summary)
|
||||
if r.Origin == "notify" && len(summary) <= 80 {
|
||||
return capital(summary), ""
|
||||
}
|
||||
return fallbackHeadline(r), capital(summary)
|
||||
}
|
||||
|
||||
// resolvedLine is the line a clearance says: the condition's own, or made from its headline.
|
||||
func resolvedLine(r *Record) string {
|
||||
line := r.Resolved
|
||||
if line == "" {
|
||||
headline, _ := wordsOf(r)
|
||||
line = "Resolved: " + lowerFirst(headline)
|
||||
}
|
||||
if !strings.HasPrefix(line, "Resolved") {
|
||||
line = "Resolved: " + lowerFirst(line)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// fallbackHeadline is a title for something with no headline: what it is about and its kind, in words.
|
||||
func fallbackHeadline(r *Record) string {
|
||||
what := strings.ReplaceAll(r.Kind, "-", " ")
|
||||
if what == "" || what == "notice" {
|
||||
what = "a notice"
|
||||
}
|
||||
about := ""
|
||||
if r.Origin == "notify" {
|
||||
about = r.Subject
|
||||
} else if machine := machineOf(r.Subject); machine != "" {
|
||||
about = machine
|
||||
} else {
|
||||
// A seat, a provider or a module is named by a name; a plan, a delivery or a call by an id, never said.
|
||||
for _, scope := range []string{"seat ", "provider ", "module "} {
|
||||
if name, ok := strings.CutPrefix(r.Subject, scope); ok {
|
||||
about = name
|
||||
}
|
||||
}
|
||||
}
|
||||
if about == "" {
|
||||
return capital(what)
|
||||
}
|
||||
return capital(what) + " on " + about
|
||||
}
|
||||
|
||||
// machineOf is the machine a record's subject words name: "machine ace" or "x on ace".
|
||||
func machineOf(subject string) string {
|
||||
if _, on, ok := strings.Cut(subject, " on "); ok {
|
||||
return on
|
||||
}
|
||||
if m, ok := strings.CutPrefix(subject, "machine "); ok {
|
||||
return m
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
codeSpan = regexp.MustCompile("`([^`]*)`")
|
||||
noise = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// plainSummary is a summary with what the operator cannot read taken out: markup and the verbs in code
|
||||
// spans. Used only where the condition says no plain words of its own.
|
||||
func plainSummary(s string) string {
|
||||
s = codeSpan.ReplaceAllStringFunc(s, func(m string) string {
|
||||
inner := strings.Trim(m, "`")
|
||||
if strings.ContainsAny(inner, ". ") {
|
||||
return "a command"
|
||||
}
|
||||
return inner
|
||||
})
|
||||
s = strings.NewReplacer("**", "", "`", "").Replace(s)
|
||||
return strings.TrimSpace(noise.ReplaceAllString(s, " "))
|
||||
}
|
||||
|
||||
// when is a moment as the operator reads it, in their zone: "10:40" today, "Tue 10:40" this week,
|
||||
// "1 Oct 10:40" before.
|
||||
func when(t, now time.Time, loc *time.Location) string {
|
||||
if t.IsZero() {
|
||||
return "now"
|
||||
}
|
||||
lt, ln := t.In(loc), now.In(loc)
|
||||
y1, m1, d1 := lt.Date()
|
||||
y2, m2, d2 := ln.Date()
|
||||
switch {
|
||||
case y1 == y2 && m1 == m2 && d1 == d2:
|
||||
return lt.Format("15:04")
|
||||
case ln.Sub(lt) < 6*24*time.Hour:
|
||||
return lt.Format("Mon 15:04")
|
||||
}
|
||||
return lt.Format("2 Jan 15:04")
|
||||
}
|
||||
|
||||
func capital(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
func lowerFirst(s string) string {
|
||||
if s == "" || (len(s) > 1 && strings.ToUpper(s[:2]) == s[:2]) {
|
||||
return s
|
||||
}
|
||||
return strings.ToLower(s[:1]) + s[1:]
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
// The notifications the operator could not read on 2026-10-08, said again from the events a controller
|
||||
// with plain words sends (novox/hq ADR 0253). BEFORE is the popup as it appeared; AFTER is what the desktop
|
||||
// shows now: a title in plain words, an explanation, since when in the operator's time — no key, id,
|
||||
// commit, verb or markup — and a clearance in one line.
|
||||
|
||||
type example struct {
|
||||
name, before string
|
||||
key, kind, severity, summary string
|
||||
subject map[string]string
|
||||
headline, explanation, resolved string
|
||||
title, body string // what the desktop shows
|
||||
cleared string // the clearance's one line
|
||||
openFor time.Duration
|
||||
}
|
||||
|
||||
var examples = []example{{
|
||||
name: "a delivery waiting",
|
||||
before: "CLEARED after 26 min: the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word " +
|
||||
"to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " +
|
||||
"plan-1791454185265004861 --why ...` starts it by hand\nabout: plan plan-1791454185265004861 (waiting)\n" +
|
||||
"since: 2026-10-08 10:40 UTC\nkey: plan.plan-1791454185265004861.waiting\nmore: conditions show " +
|
||||
"plan.plan-1791454... 65004861.waiting",
|
||||
key: "plan.plan-1791454185265004861.waiting", kind: "waiting", severity: Warning,
|
||||
subject: map[string]string{"scope": "plan", "id": "plan-1791454185265004861"},
|
||||
summary: "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's word to start: " +
|
||||
"`mesh-delivery.show` for the delivery that landed as a6385479 says why; `plans go " +
|
||||
"plan-1791454185265004861 --why …` starts it by hand",
|
||||
headline: "openrazer delivery waiting to start",
|
||||
explanation: "The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery (the " +
|
||||
"module that decides when a delivery goes out) to let it start. Nothing to do yet; it becomes urgent after " +
|
||||
"4 hours. To start it now, have an agent start it by hand.",
|
||||
resolved: "openrazer delivery no longer waiting",
|
||||
title: "openrazer delivery waiting to start",
|
||||
body: "The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery (the module " +
|
||||
"that decides when a delivery goes out) to let it start. Nothing to do yet; it becomes urgent after 4 hours. " +
|
||||
"To start it now, have an agent start it by hand.\nSince 12:40.",
|
||||
cleared: "Resolved: openrazer delivery no longer waiting, after 26 min", openFor: 26 * time.Minute,
|
||||
}, {
|
||||
name: "a module unhealthy",
|
||||
before: "WARNING: openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " +
|
||||
"service manager (exit-code)\nabout: module openrazer.g14 on g14 (module-unhealthy)\nsince: 2026-10-08 " +
|
||||
"10:40 UTC\nkey: module.openrazer.g14.unhealthy\nmore: conditions show module.openrazer.g14.unhealthy",
|
||||
key: "module.openrazer.g14.unhealthy", kind: "module-unhealthy", severity: Warning,
|
||||
subject: map[string]string{"scope": "module", "id": "openrazer.g14", "machine": "g14"},
|
||||
summary: "openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the account's own " +
|
||||
"service manager (exit-code)",
|
||||
headline: "openrazer not working on g14",
|
||||
explanation: "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as " +
|
||||
"soon as it runs again; if it stays, it needs a look at why it fails.",
|
||||
resolved: "openrazer works again on g14",
|
||||
title: "openrazer not working on g14",
|
||||
body: "openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon " +
|
||||
"as it runs again; if it stays, it needs a look at why it fails.\nSince 12:40.",
|
||||
cleared: "Resolved: openrazer works again on g14, after 2.0 h", openFor: 2 * time.Hour,
|
||||
}, {
|
||||
name: "failed units on a machine",
|
||||
before: "WARNING: shanks's service manager is degraded: 3 failed unit(s) no module places — mnt-recalbox.mount, " +
|
||||
"storage-media.mount, greenclip.service (the account's own manager). Each is the machine's own: mend or " +
|
||||
"remove it there, or have a module place it\nabout: machine shanks on shanks (machine-units)\nsince: " +
|
||||
"2026-10-08 10:40 UTC\nkey: machine.shanks.units\nmore: conditions show machine.shanks.units",
|
||||
key: "machine.shanks.units", kind: "machine-units", severity: Warning,
|
||||
subject: map[string]string{"scope": "machine", "id": "shanks", "machine": "shanks"},
|
||||
summary: "shanks's service manager is degraded: 3 failed unit(s) no module places — mnt-recalbox.mount, " +
|
||||
"storage-media.mount, greenclip.service (the account's own manager). Each is the machine's own: mend or " +
|
||||
"remove it there, or have a module place it",
|
||||
headline: "3 failed services on shanks",
|
||||
explanation: "On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module " +
|
||||
"manages them, so the mesh does not repair them: mend or remove them on shanks.",
|
||||
resolved: "No failed services on shanks any more",
|
||||
title: "3 failed services on shanks",
|
||||
body: "On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, " +
|
||||
"so the mesh does not repair them: mend or remove them on shanks.\nSince 12:40.",
|
||||
cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute,
|
||||
}, {
|
||||
name: "a healer wanted",
|
||||
before: "WARNING: \"push\" was repaired by hand 35 times in 14 days, the last by g14.node-tools, through the " +
|
||||
"mesh-controller seat: a healer is wanted for it\nabout: mesh hand-acts.push (healer-wanted)\nsince: " +
|
||||
"2026-10-08 10:40 UTC\nkey: mesh.hand-acts.push.healer-wanted\nmore: conditions show " +
|
||||
"mesh.hand-acts.push.healer-wanted",
|
||||
key: "mesh.hand-acts.push.healer-wanted", kind: "healer-wanted", severity: Warning,
|
||||
subject: map[string]string{"scope": "mesh", "id": "hand-acts.push"},
|
||||
summary: "\"push\" was repaired by hand 35 times in 14 days, the last by g14.node-tools, through the " +
|
||||
"mesh-controller seat: a healer is wanted for it",
|
||||
headline: "Push keeps being fixed by hand",
|
||||
explanation: "A person repaired push by hand 35 times in 14 days. A healer (an automatic repair) is wanted for " +
|
||||
"it; nothing is broken now.",
|
||||
resolved: "Resolved: no more hand repairs of push",
|
||||
title: "Push keeps being fixed by hand",
|
||||
body: "A person repaired push by hand 35 times in 14 days. A healer (an automatic repair) is wanted for it; " +
|
||||
"nothing is broken now.\nSince 12:40.",
|
||||
cleared: "Resolved: no more hand repairs of push, after 3 days", openFor: 3 * 24 * time.Hour,
|
||||
}}
|
||||
|
||||
// event is the body a controller with plain words sends for a condition.
|
||||
func (e example) event(at time.Time, cleared bool) []byte {
|
||||
body := map[string]any{"key": e.key, "kind": e.kind, "subject": e.subject, "severity": e.severity,
|
||||
"summary": e.summary, "headline": e.headline, "explanation": e.explanation, "resolved": e.resolved,
|
||||
"raised": "2026-10-08T10:40:00Z", "at": at.Format(time.RFC3339), "silenced": nil}
|
||||
if cleared {
|
||||
body["cleared"] = at.Format(time.RFC3339)
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestRealNotificationsReadInOneGlance(t *testing.T) {
|
||||
brussels, err := time.LoadLocation("Europe/Brussels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range examples {
|
||||
t.Run(e.name, func(t *testing.T) {
|
||||
h, _, dt, c, _ := newHolder(t)
|
||||
h.Telegram = nil
|
||||
h.Zone = func() *time.Location { return brussels }
|
||||
c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC)
|
||||
raised, err := DecodeCondition(EventRaised, e.event(c.now(), false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.Condition(EventRaised, raised)
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 1 {
|
||||
t.Fatalf("desktop sends %d", len(dt.sends))
|
||||
}
|
||||
m := dt.sends[0]
|
||||
keyless(t, m)
|
||||
if m.Title != e.title || m.Body != e.body {
|
||||
t.Errorf("shows\n %q\n %q\nwant\n %q\n %q", m.Title, m.Body, e.title, e.body)
|
||||
}
|
||||
for _, w := range []string{"`", "UTC", "key:", "more:", "about:", "plan-", "a6385479"} {
|
||||
if strings.Contains(m.Text(), w) {
|
||||
t.Errorf("it still says %q: %q", w, m.Text())
|
||||
}
|
||||
}
|
||||
c.pass(e.openFor - BurstWindow)
|
||||
cleared, err := DecodeCondition(EventCleared, e.event(c.now(), true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.Condition(EventCleared, cleared)
|
||||
done := dt.edits["1"]
|
||||
if done.Title != e.cleared || done.Body != "" {
|
||||
t.Errorf("clearance %q / %q, want one line %q", done.Title, done.Body, e.cleared)
|
||||
}
|
||||
t.Logf("\nBEFORE:\n%s\n\nAFTER:\n%s\n%s\n\nCLEARED:\n%s", e.before, m.Title, m.Body, done.Title)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **A condition from a controller without plain words is said from its summary**, its markup taken out.
|
||||
func TestAConditionWithoutPlainWordsIsSaidFromItsSummary(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h.Zone = func() *time.Location { return time.UTC }
|
||||
k := Condition{Key: "machine.ace.silent", Scope: "machine", ID: "ace", Kind: "silent", Severity: Urgent,
|
||||
Summary: "ace has not been heard from: `push ace` sends it again", Raised: c.now()}
|
||||
h.Condition(EventRaised, k)
|
||||
m := tg.sends[0]
|
||||
if m.Title != "Urgent: Silent on ace" || m.Body != "Ace has not been heard from: a command sends it again\nSince 12:00." {
|
||||
t.Errorf("%q / %q", m.Title, m.Body)
|
||||
}
|
||||
keyless(t, m)
|
||||
}
|
||||
|
||||
// **Times are the operator's**: today a clock time, this week a weekday, before that a date.
|
||||
func TestTimesAreSaidInTheOperatorsZone(t *testing.T) {
|
||||
brussels, _ := time.LoadLocation("Europe/Brussels")
|
||||
now := time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC)
|
||||
for at, want := range map[time.Time]string{
|
||||
now.Add(-56 * time.Minute): "11:44",
|
||||
now.Add(-26 * time.Hour): "Wed 10:40",
|
||||
now.Add(-10 * 24 * time.Hour): "28 Sep 12:40",
|
||||
} {
|
||||
if got := when(at, now, brussels); got != want {
|
||||
t.Errorf("%s: %q, want %q", at, got, want)
|
||||
}
|
||||
}
|
||||
if loc, err := zoneOf("Nowhere/Atlantis"); err == nil || loc != time.Local {
|
||||
t.Errorf("a zone that does not exist was not said: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"merge": "json",
|
||||
"content": "{\n \"telegram-chat-id\": \"\",\n \"desktop-machines\": []\n}\n"
|
||||
"content": "{\n \"telegram-chat-id\": \"\",\n \"desktop-machines\": [],\n \"time-zone\": \"\"\n}\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user