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=<marke>, 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.
This commit is contained in:
2026-09-01 22:59:06 +02:00
parent 831c2a953f
commit 18b40bb6ce
3 changed files with 200 additions and 6 deletions
+31
View File
@@ -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=<marke>`. 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
+161 -6
View File
@@ -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,19 +358,171 @@ 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/<id>/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/<id>/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=<zeit>`: 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=<marke>` 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.
+8
View File
@@ -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
}