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 +}