Anwesenheit anzeigen, und der Boden traegt mit 15 Sekunden
Andreas: "waehrend mmnotify per ws angemeldet ist, kann es dann den status des agenten auf online setzen?" Ja -- und hive hatte das Muster schon fertig (channels/team.py): abwesend beim Verbinden, gruen waehrend der Arbeit, wieder abwesend nach Ruhe, offline beim Beenden. Ein Agent ohne Status sieht aus, als sei niemand da; einer, der rund um die Uhr gruen leuchtet, sagt genauso wenig. Beim Testen fiel ein eigener Fehler auf: Der Boden setzte den Agenten auf gruen. Routinemaessiges Nachsehen ist aber keine Anwesenheit -- bei Boden alle 20 s und Frist 25 s blieb er dauerhaft gruen. Jetzt macht nur ein echtes Ereignis gruen. Und zur Verzoegerung, die Andreas zu Recht nicht hinnehmen wollte: Statt weiter zu raten, gemessen. Der eigene Beitrag loest sehr wohl ein posted aus, ein fremder nie -- die Verbindung hat also die Kanal-Abos, es fehlen nur fremde Ereignisse. Ausgeschlossen: authentication_challenge, Token im Authorization-Header, ein echter Bot-Account statt Nutzer-Token, und das Sitzungs-Cookie wie im Browser. Alle vier gleich. Das Mattermost-Forum beschreibt dasselbe, dort auch mit System-Admin-Token, ohne Loesung. Konsequenz: floor_seconds ist der Weg, nicht der Notnagel, und steht deshalb auf 15 s statt 60. Ein Durchgang kostet einen API-Aufruf -- billig genug, um es oft zu tun.
This commit is contained in:
@@ -32,8 +32,9 @@ Die Datei enthält ein Token und gehört mit `0600` dem Dienstbenutzer.
|
|||||||
| `on_mention` | Befehl bei Erwähnung (`@name`) im Kanal |
|
| `on_mention` | Befehl bei Erwähnung (`@name`) im Kanal |
|
||||||
| `on_direct` | Befehl bei Direktnachricht; leer = `on_mention` |
|
| `on_direct` | Befehl bei Direktnachricht; leer = `on_mention` |
|
||||||
| `debounce_seconds` | Mindestabstand zwischen zwei Läufen (Standard 5) |
|
| `debounce_seconds` | Mindestabstand zwischen zwei Läufen (Standard 5) |
|
||||||
| `floor_seconds` | Nachsehen auch ohne Ereignis (Standard 60, `0` schaltet ab) |
|
| `floor_seconds` | Nachsehen auch ohne Ereignis (Standard 15, `0` schaltet ab) |
|
||||||
| `ignore_users` | Absender, die nie wecken |
|
| `ignore_users` | Absender, die nie wecken |
|
||||||
|
| `away_after_seconds` | Wann der grüne Punkt auf abwesend zurückfällt (Standard 120) |
|
||||||
|
|
||||||
Der Befehl bekommt die Ereignisdaten über die Umgebung: `MM_TRIGGER`
|
Der Befehl bekommt die Ereignisdaten über die Umgebung: `MM_TRIGGER`
|
||||||
(`event` oder `floor`), `MM_CHANNEL`, `MM_CHANNEL_NAME`, `MM_CHANNEL_TYPE`,
|
(`event` oder `floor`), `MM_CHANNEL`, `MM_CHANNEL_NAME`, `MM_CHANNEL_TYPE`,
|
||||||
@@ -47,17 +48,52 @@ ist dort begründet: *„A stream that dies quietly must make us LATE, never
|
|||||||
blind."*
|
blind."*
|
||||||
|
|
||||||
**Hier ist der Boden nicht nur Vorsicht.** Gemessen am 2026-09-01 gegen
|
**Hier ist der Boden nicht nur Vorsicht.** Gemessen am 2026-09-01 gegen
|
||||||
Mattermost 11.7.10: Die Verbindung kommt zustande, die Anmeldung wird mit
|
Mattermost 11.7.10:
|
||||||
`status: OK` quittiert, danach kommen `hello` und `status_change` — aber
|
|
||||||
**kein `posted`**, auch wenn ein anderes Konto in einen Kanal schreibt, in dem
|
| | |
|
||||||
der Agent nachweislich Mitglied ist. Ursache offen; Verdacht sind Broadcasts an
|
|---|---|
|
||||||
Verbindungen, die über ein Personal Access Token angemeldet sind. Mit dem Boden
|
| eigener Beitrag | `posted` kommt an |
|
||||||
arbeitet der Dienst trotzdem, er ist dann nur langsamer.
|
| Beitrag eines anderen Kontos | kommt **nie** an |
|
||||||
|
|
||||||
|
Die Verbindung ist also voll funktionsfähig und hat die Kanal-Abos — es fehlen
|
||||||
|
ausschließlich die Ereignisse fremder Konten. Ausgeschlossen wurden:
|
||||||
|
`authentication_challenge`, Token im Authorization-Header beim Handshake, ein
|
||||||
|
echter Bot-Account statt eines Nutzers mit Token, und das Sitzungs-Cookie
|
||||||
|
`MMAUTHTOKEN` wie im Browser. Alle vier verhalten sich gleich.
|
||||||
|
|
||||||
|
Das Verhalten ist [im Mattermost-Forum
|
||||||
|
beschrieben](https://forum.mattermost.com/t/websocket-does-not-provide-posted-events-from-other-accounts/14876)
|
||||||
|
und tritt dort auch mit einem System-Admin-Token auf; eine Lösung steht nicht
|
||||||
|
dabei.
|
||||||
|
|
||||||
|
Deshalb ist `floor_seconds` der Weg und nicht der Notnagel, und der Standard
|
||||||
|
ist kurz: **15 Sekunden**. Ein Durchgang kostet einen API-Aufruf — billig genug,
|
||||||
|
um es oft zu tun. Wer den Stream repariert bekommt, kann ihn hochsetzen.
|
||||||
|
|
||||||
Daraus folgt die zweite Entscheidung: **mmnotify liest den Beitrag nicht aus
|
Daraus folgt die zweite Entscheidung: **mmnotify liest den Beitrag nicht aus
|
||||||
dem Ereignis.** Es klopft an; was zu tun ist, entscheidet der Agent, wenn er
|
dem Ereignis.** Es klopft an; was zu tun ist, entscheidet der Agent, wenn er
|
||||||
nachsieht. Zwei Leser wären zwei Antworten.
|
nachsieht. Zwei Leser wären zwei Antworten.
|
||||||
|
|
||||||
|
## Anwesenheit
|
||||||
|
|
||||||
|
Solange mmnotify verbunden ist, zeigt der Punkt neben dem Namen, woran man ist:
|
||||||
|
|
||||||
|
- **abwesend**, sobald die Verbindung steht — da, aber untätig
|
||||||
|
- **grün**, während der Befehl wegen einer Erwähnung läuft
|
||||||
|
- **abwesend** wieder, wenn es `away_after_seconds` still war
|
||||||
|
- **offline** beim Beenden, statt einen grünen Punkt zurückzulassen, hinter dem
|
||||||
|
niemand mehr sitzt
|
||||||
|
|
||||||
|
Das routinemäßige Nachsehen des Bodens macht ausdrücklich **nicht** grün. Läuft
|
||||||
|
der Boden häufiger als die Away-Frist, leuchtete der Agent sonst rund um die
|
||||||
|
Uhr — und ein Punkt, der immer grün ist, sagt genauso wenig wie gar keiner.
|
||||||
|
(Beim Testen genau so passiert: Boden alle 20 s, Frist 25 s, nie abwesend.)
|
||||||
|
|
||||||
|
Ein Status ist eine Höflichkeit gegenüber dem, der in die Seitenleiste schaut.
|
||||||
|
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.
|
||||||
|
|
||||||
## Robustheit
|
## Robustheit
|
||||||
|
|
||||||
- Wiederaufbau mit wachsendem Abstand (1 s bis 60 s). Ein Server, der neu
|
- Wiederaufbau mit wachsendem Abstand (1 s bis 60 s). Ein Server, der neu
|
||||||
|
|||||||
@@ -17,12 +17,28 @@
|
|||||||
// Latenz statt Nachrichten.
|
// Latenz statt Nachrichten.
|
||||||
//
|
//
|
||||||
// Das ist hier keine Vorsichtsmaßnahme, sondern nötig. Gemessen am 2026-09-01
|
// Das ist hier keine Vorsichtsmaßnahme, sondern nötig. Gemessen am 2026-09-01
|
||||||
// gegen team.42i.org (Mattermost 11.7.10): Die Verbindung kommt zustande, die
|
// gegen Mattermost 11.7.10:
|
||||||
// Anmeldung wird mit "status: OK" quittiert, und danach kommen `hello` und
|
//
|
||||||
// `status_change` — aber KEIN `posted`, auch wenn ein anderes Konto in einen
|
// eigener Beitrag -> `posted` kommt an
|
||||||
// Kanal schreibt, in dem der Agent nachweislich Mitglied ist. Ursache noch
|
// Beitrag eines anderen -> kommt NIE an
|
||||||
// offen (Verdacht: Broadcasts an Verbindungen, die über ein Personal Access
|
//
|
||||||
// Token angemeldet sind). Mit dem Boden funktioniert der Dienst trotzdem.
|
// Die Verbindung ist also voll funktionsfähig und hat die Kanal-Abos; es
|
||||||
|
// fehlen ausschließlich die Ereignisse fremder Konten. Vier Varianten wurden
|
||||||
|
// ausgeschlossen: Anmeldung per `authentication_challenge`, Token im
|
||||||
|
// Authorization-Header beim Handshake, ein echter Bot-Account (statt Nutzer
|
||||||
|
// mit Token) und das Sitzungs-Cookie MMAUTHTOKEN wie im Browser. Alle vier
|
||||||
|
// verhalten sich gleich.
|
||||||
|
//
|
||||||
|
// Das Verhalten ist bekannt und nicht auf uns beschränkt:
|
||||||
|
// https://forum.mattermost.com/t/websocket-does-not-provide-posted-events-from-other-accounts/14876
|
||||||
|
// Dort tritt es auch mit einem System-Admin-Token auf, und eine Lösung steht
|
||||||
|
// nicht dabei.
|
||||||
|
//
|
||||||
|
// Konsequenz für den Betrieb: floor_seconds ist der Weg, nicht der Notnagel,
|
||||||
|
// und der Standard ist deshalb bewusst kurz (15 s). Ein Durchgang kostet einen
|
||||||
|
// API-Aufruf — das ist billig genug, um es oft zu tun, und 15 Sekunden fühlen
|
||||||
|
// sich noch wie eine Antwort an. Wer den Stream repariert bekommt, kann den
|
||||||
|
// Boden hochsetzen; bis dahin trägt er.
|
||||||
//
|
//
|
||||||
// Deshalb gilt auch: mmnotify liest den Beitrag NICHT aus dem Ereignis. Es
|
// Deshalb gilt auch: mmnotify liest den Beitrag NICHT aus dem Ereignis. Es
|
||||||
// klopft nur an; was zu tun ist, entscheidet der Agent, wenn er nachsieht.
|
// klopft nur an; was zu tun ist, entscheidet der Agent, wenn er nachsieht.
|
||||||
@@ -65,12 +81,16 @@ type Config struct {
|
|||||||
// Minute fünf Agentenläufe auslösen.
|
// Minute fünf Agentenläufe auslösen.
|
||||||
DebounceSeconds int `json:"debounce_seconds"`
|
DebounceSeconds int `json:"debounce_seconds"`
|
||||||
// Der Boden unter dem Stream: Auch ohne Ereignis wird nach so vielen
|
// Der Boden unter dem Stream: Auch ohne Ereignis wird nach so vielen
|
||||||
// Sekunden nachgesehen. Standard 60. Auf 0 zu setzen heißt, sich allein auf
|
// Sekunden nachgesehen. Standard 15 — kurz, weil der Stream fremde Beiträge
|
||||||
// den Stream zu verlassen — siehe die Messung im Kopf dieser Datei.
|
// nicht liefert (siehe Kopf dieser Datei). Auf 0 zu setzen heißt, sich allein
|
||||||
|
// auf den Stream zu verlassen; das hört heute nur die eigenen Beiträge.
|
||||||
FloorSeconds int `json:"floor_seconds"`
|
FloorSeconds int `json:"floor_seconds"`
|
||||||
// Antwortet der Agent auf eigene Beiträge, entsteht eine Schleife. Der
|
// Antwortet der Agent auf eigene Beiträge, entsteht eine Schleife. Der
|
||||||
// eigene Benutzer wird beim Start ermittelt und hier nicht gebraucht.
|
// eigene Benutzer wird beim Start ermittelt und hier nicht gebraucht.
|
||||||
IgnoreUsers []string `json:"ignore_users"`
|
IgnoreUsers []string `json:"ignore_users"`
|
||||||
|
// Nach wie vielen Sekunden ohne Arbeit der Punkt von grün auf abwesend
|
||||||
|
// zurückfällt. Standard 120, 0 schaltet die Statusanzeige ganz ab.
|
||||||
|
AwayAfterSeconds int `json:"away_after_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Ereignis struct {
|
type Ereignis struct {
|
||||||
@@ -88,6 +108,9 @@ type Melder struct {
|
|||||||
letzter time.Time
|
letzter time.Time
|
||||||
laufMu sync.Mutex
|
laufMu sync.Mutex
|
||||||
gesehen map[string]bool
|
gesehen map[string]bool
|
||||||
|
statusMu sync.Mutex
|
||||||
|
status string // zuletzt gesetzter Status; nur über statusSetzen anfassen
|
||||||
|
aktiv time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -110,7 +133,10 @@ func main() {
|
|||||||
cfg.DebounceSeconds = 5
|
cfg.DebounceSeconds = 5
|
||||||
}
|
}
|
||||||
if cfg.FloorSeconds == 0 {
|
if cfg.FloorSeconds == 0 {
|
||||||
cfg.FloorSeconds = 60
|
cfg.FloorSeconds = 15
|
||||||
|
}
|
||||||
|
if cfg.AwayAfterSeconds == 0 {
|
||||||
|
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{}}
|
||||||
@@ -126,12 +152,18 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
<-schluss
|
<-schluss
|
||||||
log.Print("Beende auf Signal.")
|
log.Print("Beende auf Signal.")
|
||||||
|
// Offline sagen, statt einen grünen Punkt zurückzulassen, hinter dem
|
||||||
|
// niemand mehr sitzt.
|
||||||
|
m.statusSetzen("offline")
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if cfg.FloorSeconds > 0 {
|
if cfg.FloorSeconds > 0 {
|
||||||
go m.boden()
|
go m.boden()
|
||||||
}
|
}
|
||||||
|
if cfg.AwayAfterSeconds > 0 {
|
||||||
|
go m.abwesendWennStill()
|
||||||
|
}
|
||||||
m.verbindungsschleife()
|
m.verbindungsschleife()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +238,8 @@ func (m *Melder) lauschen() error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Da, aber untätig. Grün ab der ersten Sekunde würde nichts aussagen.
|
||||||
|
m.statusSetzen("away")
|
||||||
log.Print("Verbunden, warte auf Ereignisse.")
|
log.Print("Verbunden, warte auf Ereignisse.")
|
||||||
for {
|
for {
|
||||||
var ev Ereignis
|
var ev Ereignis
|
||||||
@@ -296,6 +330,15 @@ func (m *Melder) wecken(befehl string, umgebung map[string]string) {
|
|||||||
}
|
}
|
||||||
m.letzter = time.Now()
|
m.letzter = time.Now()
|
||||||
|
|
||||||
|
// Grün heißt: Es passiert gerade etwas, das jemanden angeht. Das routinemäßige
|
||||||
|
// Nachsehen des Bodens gehört ausdrücklich NICHT dazu -- läuft er häufiger als
|
||||||
|
// die Away-Frist, leuchtete der Agent sonst rund um die Uhr, und ein Punkt,
|
||||||
|
// der immer grün ist, sagt genauso wenig wie gar keiner (beim Testen am
|
||||||
|
// 2026-09-01 genau so passiert: Boden alle 20 s, Frist 25 s, nie abwesend).
|
||||||
|
if umgebung["MM_TRIGGER"] != "floor" {
|
||||||
|
m.aktiv = time.Now()
|
||||||
|
m.statusSetzen("online")
|
||||||
|
}
|
||||||
log.Printf("Wecke wegen Beitrag von %s in %s", umgebung["MM_SENDER"], umgebung["MM_CHANNEL_NAME"])
|
log.Printf("Wecke wegen Beitrag von %s in %s", umgebung["MM_SENDER"], umgebung["MM_CHANNEL_NAME"])
|
||||||
cmd := exec.Command("/bin/sh", "-c", befehl)
|
cmd := exec.Command("/bin/sh", "-c", befehl)
|
||||||
cmd.Env = os.Environ()
|
cmd.Env = os.Environ()
|
||||||
@@ -323,6 +366,56 @@ func (m *Melder) boden() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// Seitenleiste schaut. Weigert sich der Server, muss die Arbeit trotzdem
|
||||||
|
// stattfinden: Ein Agent, der aufhört zu antworten, weil er einen Punkt nicht
|
||||||
|
// einfärben konnte, wäre absurd (übernommen aus hive, channels/team.py).
|
||||||
|
//
|
||||||
|
// Warum überhaupt zwei Zustände: Ein Agent ganz ohne Status sieht aus, als sei
|
||||||
|
// niemand da. Einer, der rund um die Uhr grün leuchtet, sagt genauso wenig.
|
||||||
|
// Beides ist schlechter als die Wahrheit — da, und das ist der Zeitpunkt, an
|
||||||
|
// dem zuletzt etwas geschah.
|
||||||
|
func (m *Melder) statusSetzen(neu string) {
|
||||||
|
m.statusMu.Lock()
|
||||||
|
if m.cfg.AwayAfterSeconds < 0 || m.status == neu {
|
||||||
|
m.statusMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.statusMu.Unlock()
|
||||||
|
koerper, _ := json.Marshal(map[string]string{"user_id": m.ich, "status": neu})
|
||||||
|
req, _ := http.NewRequest("PUT", m.cfg.URL+"/api/v4/users/"+m.ich+"/status",
|
||||||
|
strings.NewReader(string(koerper)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+m.cfg.Token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Status %q nicht gesetzt: %v", neu, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
log.Printf("Status %q nicht gesetzt: HTTP %d", neu, r.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.statusMu.Lock()
|
||||||
|
m.status = neu
|
||||||
|
m.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// abwesendWennStill lässt das Grün verblassen, sobald es still geworden ist.
|
||||||
|
func (m *Melder) abwesendWennStill() {
|
||||||
|
t := time.NewTicker(15 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
if !m.aktiv.IsZero() &&
|
||||||
|
time.Since(m.aktiv) > time.Duration(m.cfg.AwayAfterSeconds)*time.Second {
|
||||||
|
m.statusSetzen("away")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Melder) ichErmitteln() error {
|
func (m *Melder) ichErmitteln() error {
|
||||||
req, _ := http.NewRequest("GET", m.cfg.URL+"/api/v4/users/me", nil)
|
req, _ := http.NewRequest("GET", m.cfg.URL+"/api/v4/users/me", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+m.cfg.Token)
|
req.Header.Set("Authorization", "Bearer "+m.cfg.Token)
|
||||||
|
|||||||
Reference in New Issue
Block a user