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
+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,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/<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.
//
// Der Punkt neben dem Namen ist eine Höflichkeit gegenüber dem, der in die