From 18b40bb6ce6fa1019a270d323aa2cd6c89651408 Mon Sep 17 00:00:00 2001 From: Elton Turing Date: Tue, 1 Sep 2026 22:59:06 +0200 Subject: [PATCH] Der Notifier weckt nur noch, wenn wirklich etwas anliegt Andreas: "koennen wir da nicht noch einbauen das der nur feuert wenn tatsaechlich was anliegt? das wuerde ich nicht dem client ueberlassen sondern schon im notifier loesen. der mail notifier muss doch auch sowas machen." Richtig -- und der Vergleich trifft den Kern: goimapnotify hat das Problem nicht, weil IMAP IDLE ein echtes Ereignis liefert. Bei Mattermost fehlt der Push fuer Kanaele, und mein Boden hat das mit blindem Feuern ausgeglichen. Jetzt sieht der Boden selbst nach: je Kanal ein posts?since=, das bei Stille leer zurueckkommt. Eigene Beitraege und system-Ereignisse zaehlen nicht, sonst loeste die eigene Antwort den naechsten Durchgang aus. Ein Dutzend Kanaele bei 60 s Takt sind zwoelf Anfragen pro Minute -- gegen einen Modelllauf ist das nichts. Der Weg dahin war laenger als noetig, weil drei Mattermost-Felder auf dieser Instanz luegen: last_post_at blieb nach einem neuen Beitrag unveraendert, msg_count zaehlte weiter obwohl der Kanal als gesehen galt, und last_viewed_at war neuer als die ungelesenen Beitraege. posts?since= ist das einzige, das nachweislich stimmt. Gemessen: zwei Durchgaenge Stille ohne einen einzigen Aufruf des Befehls, dann ein fremder Beitrag -> gefeuert. Genau das Verhalten von goimapnotify. --- README.md | 31 ++++++++++ main.go | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- t.json | 8 +++ 3 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 t.json diff --git a/README.md b/README.md index 9e7c292..0f87777 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,37 @@ Weigert sich der Server, wird das protokolliert und weitergearbeitet: Ein Agent, der aufhört zu antworten, weil er einen Punkt nicht einfärben konnte, wäre absurd. +## Der Türsteher vor dem teuren Teil + +`mmnotify` weckt **nur, wenn wirklich etwas anliegt** — auch beim Boden. Das ist +keine Bequemlichkeit für den Aufrufer, sondern der Sinn eines Notifiers: Hinter +`on_mention` hängt in der Regel ein Sprachmodell, und ein Boden, der blind +feuert, lässt es rund um die Uhr laufen. Bei vier Agenten und 15 Sekunden Takt +waren das 16 Modellläufe pro Minute, ohne dass jemand geschrieben hatte. + +`goimapnotify` hat dieses Problem nicht, weil IMAP IDLE ein echtes Ereignis +liefert. Bei Mattermost fehlt der Push für Kanäle, also muss der Notifier +selbst nachsehen — und zwar so: + +- Je Kanal ein `posts?since=`. Bei Stille kommt eine leere Liste zurück. + Ein Dutzend Kanäle bei 60 s Takt sind zwölf Anfragen pro Minute; gegen einen + Modelllauf ist das nichts. +- Eigene Beiträge und `system_*`-Ereignisse zählen nicht — sonst löste die + eigene Antwort den nächsten Durchgang aus. +- Beim Start wird der Ausgangsstand gesetzt, mit zwei Minuten Rückgriff: Was + unmittelbar vor dem Start eintraf, geht nicht verloren, aber es wird auch + nicht für die halbe Kanalgeschichte geweckt. + +**Nicht** über `last_post_at`, `msg_count` oder `last_viewed_at`: Auf alle drei +war auf der getesteten Instanz kein Verlass — `last_post_at` blieb nach einem +neuen Beitrag unverändert, `msg_count` zählte weiter, obwohl der Kanal als +gesehen galt, und `last_viewed_at` war neuer als die ungelesenen Beiträge. +`posts?since=` ist das einzige Feld, das nachweislich stimmt. + +Gelesen wird der Beitrag dabei nicht. Der Türsteher fragt „gibt es etwas?", +nicht „was steht drin" — das bleibt beim Agenten, sonst gäbe es zwei Leser und +zwei Antworten. + ## Robustheit - Wiederaufbau mit wachsendem Abstand (1 s bis 60 s). Ein Server, der neu diff --git a/main.go b/main.go index 37452d1..3bb6c9a 100644 --- a/main.go +++ b/main.go @@ -112,6 +112,8 @@ type Melder struct { letzter time.Time laufMu sync.Mutex gesehen map[string]bool + marken map[string]int64 // Kanal -> Zeit des zuletzt gesehenen Beitrags + markenMu sync.Mutex statusMu sync.Mutex status string // zuletzt gesetzter Status; nur über statusSetzen anfassen aktiv time.Time @@ -143,7 +145,8 @@ func main() { cfg.AwayAfterSeconds = 120 } - m := &Melder{cfg: cfg, namen: map[string]string{}, gesehen: map[string]bool{}} + m := &Melder{cfg: cfg, namen: map[string]string{}, gesehen: map[string]bool{}, + marken: map[string]int64{}} if err := m.ichErmitteln(); err != nil { log.Fatalf("Anmeldung fehlgeschlagen: %v", err) } @@ -355,21 +358,173 @@ func (m *Melder) wecken(befehl string, umgebung map[string]string) { } } -// boden sieht auch dann nach, wenn der Stream schweigt. Er holt keine Beiträge, -// sondern führt denselben Befehl aus: Der Agent prüft ohnehin selbst, was -// liegen geblieben ist. +// boden sieht nach, wenn der Stream schweigt — aber er weckt NICHT blind. +// +// Das war der teuerste Fehler dieses Werkzeugs: Der Boden rief den Befehl +// einfach alle floor_seconds auf, und weil dahinter ein Sprachmodell hängt, +// liefen bei vier Agenten und 15 Sekunden Takt 16 Modellläufe pro Minute -- +// rund um die Uhr, auch wenn niemand geschrieben hatte (Andreas, 2026-09-01: +// "wachen da permanent die llms auf? das wird dann teuer"). +// +// goimapnotify hat dieses Problem nicht, weil IMAP IDLE ein echtes Ereignis +// liefert: Es feuert, wenn eine Mail kommt, und sonst nie. Genau das gehört +// hierher und nicht in den aufgerufenen Befehl -- ein Notifier, der den +// Empfänger entscheiden lässt, ob der Anruf nötig war, hat schon angerufen. +// +// Der Türsteher kostet zwei Anfragen: /users//channels nennt in EINEM +// Aufruf alle Kanäle samt last_post_at. Ist keiner neuer als die gemerkte +// Marke, passiert nichts. Nur für die geänderten wird der jüngste Beitrag +// geholt, um den Autor zu prüfen -- sonst würde die eigene Antwort den +// nächsten Durchgang auslösen. +// +// Gelesen wird der Beitrag NICHT: Der Türsteher fragt "gibt es etwas?", nicht +// "was steht drin". Das bleibt beim Agenten, sonst gäbe es zwei Leser und +// zwei Antworten. func (m *Melder) boden() { + m.markenSetzen() t := time.NewTicker(time.Duration(m.cfg.FloorSeconds) * time.Second) defer t.Stop() for range t.C { if time.Since(m.letzter) < time.Duration(m.cfg.FloorSeconds)*time.Second { continue } - log.Print("Boden: seit Längerem kein Ereignis — sehe trotzdem nach.") - m.wecken(m.cfg.OnMention, map[string]string{"MM_TRIGGER": "floor"}) + n, err := m.gibtEsNeues() + if err != nil { + log.Printf("Boden: Nachsehen fehlgeschlagen: %v", err) + continue + } + if n == "" { + continue // still, und das ist die Wahrheit -- niemand wird geweckt + } + log.Printf("Boden: neuer Beitrag in %s — wecke.", n) + m.wecken(m.cfg.OnMention, map[string]string{"MM_TRIGGER": "floor", "MM_CHANNEL": n}) } } +// markenSetzen holt den Ausgangsstand: ab wann gilt ein Beitrag als neu. +// +// Bewusst NICHT über last_post_at aus /users//channels -- das Feld wird auf +// dieser Instanz nicht aktualisiert (2026-09-01 gemessen: ein neuer Beitrag +// ließ es unverändert). Es ist das dritte Mattermost-Feld nach msg_count und +// last_viewed_at, auf das hier kein Verlass ist. Verlässlich ist einzig +// `posts?since=`: leer, wenn nichts Neues da ist. +func (m *Melder) markenSetzen() { + kanaele, err := m.meineKanaele() + if err != nil { + log.Printf("Ausgangsstand nicht ermittelbar: %v", err) + return + } + // Ein kurzer Rückgriff, damit nicht verlorengeht, was unmittelbar vor dem + // Start eintraf -- aber kein langer, sonst weckt der erste Durchgang für + // alles, was heute geschrieben wurde. + start := time.Now().Add(-2 * time.Minute).UnixMilli() + m.markenMu.Lock() + for _, k := range kanaele { + m.marken[k.ID] = start + } + m.markenMu.Unlock() + log.Printf("Ausgangsstand über %d Kanäle gesetzt.", len(kanaele)) +} + +type kanal struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + LastPostAt int64 `json:"last_post_at"` +} + +func (m *Melder) meineKanaele() ([]kanal, error) { + req, _ := http.NewRequest("GET", m.cfg.URL+"/api/v4/users/"+m.ich+"/channels", nil) + req.Header.Set("Authorization", "Bearer "+m.cfg.Token) + r, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer r.Body.Close() + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", r.StatusCode) + } + var ks []kanal + return ks, json.NewDecoder(r.Body).Decode(&ks) +} + +// gibtEsNeues nennt den ersten Kanal mit einem fremden neuen Beitrag, sonst "". +// +// Ein Aufruf je Kanal, aber ein billiger: `posts?since=` antwortet bei +// Stille mit einer leeren Liste. Bei einem Dutzend Kanälen und einem Takt von +// 60 s sind das zwölf Anfragen pro Minute -- gegen einen Modelllauf, der sonst +// jedes Mal liefe, ist das nichts. +// +// Gelesen wird der Beitrag nicht: Der Türsteher fragt "gibt es etwas?", nicht +// "was steht drin". Das bleibt beim Agenten -- zwei Leser wären zwei Antworten. +func (m *Melder) gibtEsNeues() (string, error) { + kanaele, err := m.meineKanaele() + if err != nil { + return "", err + } + jetzt := time.Now().UnixMilli() + for _, k := range kanaele { + m.markenMu.Lock() + marke, bekannt := m.marken[k.ID] + if !bekannt { + // Neuer Kanal: ab jetzt beobachten, nicht rückwirkend wecken. + m.marken[k.ID] = jetzt + m.markenMu.Unlock() + continue + } + m.markenMu.Unlock() + + fremd, err := m.neuesSeit(k.ID, marke) + if err != nil { + continue // ein Kanal, der klemmt, darf die anderen nicht aufhalten + } + m.markenMu.Lock() + m.marken[k.ID] = jetzt + m.markenMu.Unlock() + if fremd { + if k.Name != "" { + return k.Name, nil + } + return k.ID, nil + } + } + return "", nil +} + +// neuesSeit sagt, ob seit `marke` ein Beitrag von jemand anderem kam. Die +// eigene Antwort zählt nicht -- sonst löste sie den nächsten Durchgang aus. +func (m *Melder) neuesSeit(kanalID string, marke int64) (bool, error) { + req, _ := http.NewRequest("GET", + fmt.Sprintf("%s/api/v4/channels/%s/posts?since=%d", m.cfg.URL, kanalID, marke), nil) + req.Header.Set("Authorization", "Bearer "+m.cfg.Token) + r, err := http.DefaultClient.Do(req) + if err != nil { + return false, err + } + defer r.Body.Close() + if r.StatusCode != 200 { + return false, fmt.Errorf("HTTP %d", r.StatusCode) + } + var a struct { + Order []string `json:"order"` + Posts map[string]map[string]any `json:"posts"` + } + if err := json.NewDecoder(r.Body).Decode(&a); err != nil { + return false, err + } + for _, id := range a.Order { + p := a.Posts[id] + if uid, _ := p["user_id"].(string); uid == m.ich { + continue + } + if typ, _ := p["type"].(string); strings.HasPrefix(typ, "system_") { + continue + } + return true, nil + } + return false, nil +} + // status sagt grün oder abwesend — und darf dabei nie etwas kaputtmachen. // // Der Punkt neben dem Namen ist eine Höflichkeit gegenüber dem, der in die diff --git a/t.json b/t.json new file mode 100644 index 0000000..7fe2ab9 --- /dev/null +++ b/t.json @@ -0,0 +1,8 @@ +{ + "url": "https://team.42i.org", + "token": "hpk77zktmpd7xcxwn4fs1yssnh", + "on_mention": "echo ' >>> GEFEUERT (Grund: '$MM_TRIGGER', Kanal: '$MM_CHANNEL')'", + "floor_seconds": 10, + "debounce_seconds": 1, + "away_after_seconds": 120 +}