messenger and dunst: plain notifications that say whether you are needed, and take the answer from the desk (hq ADR 0253, issue 320) #126
@@ -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