Files
mesh-catalog/modules/messenger/cmd/messenger/main.go
T
jochen d83711f11e
mesh/merge-gate pass: builds dunst, messenger → g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/notifications-after-review ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
Take only acknowledgements from a desk click, and make each answer a one-time ticket
Review found that any process of the operator's account can choose a dunst action,
so a click could release or stop a delivery in the operator's name; that tokens
lived for ever; that a warning's answers died in ten seconds; and that a restarted
notifier left dead buttons (hq ADR 0258).
2026-10-08 14:38:24 +02:00

444 lines
15 KiB
Go

// messenger: the holder of the operator-channel seat (novox/hq to-be 45 §5, ADR 0227, research 028).
// A Go bundle the node's runtime launches. It consumes the controller's condition events and decides
// what is said to the operator, on Telegram and on the desktop notifier of the machine the operator
// is at. It keeps its open messages in its own state, so a restart forgets nothing. stdout is the MCP
// channel; what this module says, it says on stderr.
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
stdio "git.novox.be/novox/mesh-sdk/go"
)
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
// Settings are the operator's values for this module, merged by the mesh into one JSON file.
type Settings struct {
TelegramChatID string `json:"telegram-chat-id"`
DesktopMachines []string `json:"desktop-machines"`
// TimeZone is the operator's, an IANA name such as Europe/Brussels: every time a message says is said
// in it. Empty is this machine's own zone.
TimeZone string `json:"time-zone"`
}
// zoneOf is the location a time-zone setting names, or this machine's own when it names none or one
// that does not exist (said, never guessed silently).
func zoneOf(name string) (*time.Location, error) {
if name == "" {
return time.Local, nil
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.Local, fmt.Errorf("the time-zone %q is not one this machine knows; times are said in its own zone", name)
}
return loc, nil
}
func readSettings(path string) (Settings, error) {
var s Settings
if path == "" {
return s, errors.New("started without a settings file")
}
raw, err := os.ReadFile(path)
if err != nil {
return s, err
}
// The chat id may be given as a number.
var loose map[string]any
if err := json.Unmarshal(raw, &loose); err != nil {
return s, fmt.Errorf("the settings file is not JSON: %v", err)
}
switch v := loose["telegram-chat-id"].(type) {
case string:
s.TelegramChatID = strings.TrimSpace(v)
case float64:
s.TelegramChatID = fmt.Sprintf("%.0f", v)
}
if tz, ok := loose["time-zone"].(string); ok {
s.TimeZone = strings.TrimSpace(tz)
}
if list, ok := loose["desktop-machines"].([]any); ok {
for _, m := range list {
if name, ok := m.(string); ok && strings.TrimSpace(name) != "" {
s.DesktopMachines = append(s.DesktopMachines, strings.TrimSpace(name))
}
}
}
return s, nil
}
// stateStore keeps the open messages in the module's declared state `open`, and the recent sends
// under one key of `sent` (ADR 0201).
type stateStore struct{}
// kvKey is a condition key as a bucket key: only the characters a key may carry.
func kvKey(key string) string {
var b strings.Builder
for _, r := range key {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '=':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
return strings.Trim(b.String(), ".")
}
func (stateStore) Put(r Record) error {
_, err := stdio.State("open").Put(kvKey(r.Key), r)
return err
}
func (stateStore) Delete(key string) error { return stdio.State("open").Delete(kvKey(key)) }
func (stateStore) All() ([]Record, error) {
keys, err := stdio.State("open").Keys()
if err != nil {
return nil, err
}
var out []Record
for _, k := range keys {
e, err := stdio.State("open").Get(k)
if err != nil {
return nil, err
}
if e == nil {
continue
}
var r Record
if err := json.Unmarshal(e.Value, &r); err != nil {
logf("[messenger] the open message kept as %s cannot be read (%v); left as it is", k, err)
continue
}
out = append(out, r)
}
return out, nil
}
func (stateStore) PutRecent(s []Sent) error {
_, err := stdio.State("sent").Put("recent", s)
return err
}
func (stateStore) Recent() ([]Sent, error) {
e, err := stdio.State("sent").Get("recent")
if err != nil || e == nil {
return nil, err
}
var s []Sent
return s, json.Unmarshal(e.Value, &s)
}
// listening is whether condition events reach this holder, in words.
type listening struct {
mu sync.Mutex
now string
}
func (l *listening) set(s string) { l.mu.Lock(); l.now = s; l.mu.Unlock() }
func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.now }
func main() {
settingsFile := os.Getenv("MESH_MESSENGER_SETTINGS")
var said sync.Mutex
lastSaid, zoneSaid := "", ""
settings := func() Settings {
s, err := readSettings(settingsFile)
said.Lock()
defer said.Unlock()
if err != nil && err.Error() != lastSaid {
logf("[messenger] settings cannot be read: %v", err)
}
lastSaid = ""
if err != nil {
lastSaid = err.Error()
}
return s
}
h := &Holder{
Telegram: NewTelegram(TelegramConfig{
TokenFile: os.Getenv("MESH_MESSENGER_TELEGRAM_TOKEN_FILE"),
ChatID: func() string { return settings().TelegramChatID },
}),
Desktop: &Desktop{
Machines: func() []string { return settings().DesktopMachines },
Ask: stdio.Ask,
},
Store: stateStore{},
Ask: stdio.Ask,
Logf: logf,
Zone: func() *time.Location {
loc, err := zoneOf(settings().TimeZone)
said.Lock()
defer said.Unlock()
if err != nil && err.Error() != zoneSaid {
logf("[messenger] %v", err)
}
zoneSaid = ""
if err != nil {
zoneSaid = err.Error()
}
return loc
},
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
}
h.init()
l := &listening{now: "not yet: starting"}
go run(h, l)
if err := stdio.Serve("", tools(h, l)); err != nil {
logf("%v", err)
os.Exit(1)
}
}
// run reads back the state, listens for condition events, and keeps time — each retried, and each
// failure said, never given up on quietly.
func run(h *Holder, l *listening) {
time.Sleep(500 * time.Millisecond) // Serve first: the state is reached through it
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := h.Load()
if err == nil {
break
}
logf("[messenger] cannot read back its open messages yet (%v); asking again in %s", err, wait)
time.Sleep(wait)
}
for _, ch := range h.channels() {
if err := ch.Ready(); err != nil {
logf("[messenger] %s cannot send: %v", ch.Name(), err)
}
}
// The state first: what is open now is read from the controller before any event is taken, so
// what the stream replays is history (novox/hq issue 271). Not reachable yet, it is asked for
// again in the loop below, and old events meanwhile are kept as state, never said.
h.Start()
syncFromController(h)
go func() {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
lastTick, lastAsk := time.Now(), time.Now()
for now := range tick.C {
h.Flush()
if now.Sub(lastTick) >= time.Minute {
lastTick = now
h.Tick()
}
if h.WantsSync() && now.Sub(lastAsk) >= time.Minute {
lastAsk = now
syncFromController(h)
}
}
}()
handle := func(e stdio.Envelope) error {
event, ok := eventOf(e.Key)
if !ok {
return nil
}
c, err := DecodeCondition(event, e.Body)
if err != nil {
h.Unreadable(e.Key, err)
return nil
}
h.Condition(event, c)
return nil
}
go listenForAnswers(h)
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(ControllerSeat+".*", handle)
if err == nil {
l.set("listening")
logf("[messenger] listening for the controller's condition events")
return
}
l.set("not yet: " + err.Error())
logf("[messenger] not hearing condition events yet (%v); asking again in %s", err, wait)
time.Sleep(wait)
}
}
// AnswerEvent is the desk's notifier saying which answer was chosen, and StartedEvent that it started (so
// the answers it waited for are lost): the dunst holder of node-notifier emits them as its module's events
// until seats publish events of their own (to-be 46 phase 3).
const (
AnswerEvent = "dunst.action-chosen"
StartedEvent = "dunst.started"
)
// listenForAnswers takes the answers the operator chooses on the desk, retried until it can.
func listenForAnswers(h *Holder) {
handle := func(e stdio.Envelope) error {
if e.Key == StartedEvent {
var body struct {
Machine string `json:"machine"`
}
_ = json.Unmarshal(e.Body, &body)
if body.Machine == "" {
body.Machine = e.Node
}
h.Reoffer(body.Machine)
return nil
}
if e.Key != AnswerEvent {
return nil
}
var body struct {
Token string `json:"token"`
Machine string `json:"machine"`
}
if err := json.Unmarshal(e.Body, &body); err != nil || body.Token == "" {
logf("[messenger] an answer from the desk could not be read: %s", firstLineOf(string(e.Body)))
return nil
}
machine := body.Machine
if machine == "" {
machine = e.Node
}
// The machine is the event's own node, said by the runtime: the body's word is only a hint.
if e.Node != "" {
machine = e.Node
}
_, _ = h.Chosen("desktop", machine, body.Token, e.Headers["x-event-id"])
return nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.Subscribe("dunst.*", handle); err == nil {
logf("[messenger] listening for the operator's answers on the desk")
return
} else {
logf("[messenger] not hearing the desk's answers yet (%v); asking again in %s", err, wait)
}
time.Sleep(wait)
}
}
// syncFromController reads the controller's open conditions and gives them to the holder.
func syncFromController(h *Holder) {
asked := h.Now()
type answer struct {
raw json.RawMessage
err error
}
done := make(chan answer, 1)
go func() {
raw, err := stdio.Ask("seat:"+ControllerSeat+".conditions", map[string]any{})
done <- answer{raw, err}
}()
var a answer
select {
case a = <-done:
case <-time.After(30 * time.Second):
a.err = errors.New("no answer in 30 s")
}
if a.err != nil {
h.SyncFailed(a.err)
return
}
open, err := DecodeOpen(a.raw)
if err != nil {
h.SyncFailed(err)
return
}
h.Sync(open, asked)
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func strArg(a map[string]any, k string) string { s, _ := a[k].(string); return strings.TrimSpace(s) }
func limitArg(a map[string]any, def int) int {
if v, ok := a["limit"].(float64); ok && v >= 1 {
return min(int(v), KeptSends)
}
return def
}
func tools(h *Holder, l *listening) []stdio.Tool {
return []stdio.Tool{
{Name: "operator-channel.open",
Description: "What is open now: every message the operator was sent about something still wrong, urgent first, " +
"oldest first — its key, severity, summary, since when, the channels it went to, whether it is silenced, " +
"reminded, held (for its burst window, the desktop's rhythm or the cap), refused, not sent yet, or " +
"never said (known from the state or an old event: history, not news).",
Run: func(map[string]any) (any, error) { return h.Open(), nil }},
{Name: "operator-channel.history",
Description: "What was said to the operator lately, newest first — each send, digest, edit and failure with its " +
"channel, key and outcome — and every message refused for carrying an address, a path or a secret.",
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many of each (default 50)"}},
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 50)), nil }},
{Name: "operator-channel.notify",
Description: "Tell the operator something, as a module that uses the seat: a key (the same key is the same " +
"message), urgent or warning, one line in the mesh's words, and what it is about; and best, in plain " +
"words, a headline, an explanation and the line said when it is over. clear says it is over. " +
"Roles and words only: an address, a path or a secret is refused. Deduplicated, capped and routed " +
"like the controller's conditions.",
Input: map[string]any{
"key": str("what makes it the same message the next time, e.g. backup.ace.failed"),
"severity": map[string]any{"type": "string", "enum": []string{Urgent, Warning}},
"summary": str("one line in the mesh's words"),
"subject": str("what it is about: a machine's role, a module, a plan"),
"headline": str("a few plain words the operator reads as the title, e.g. backup failed on ace: no ids, keys or commands"),
"explanation": str("one or two plain sentences: what happened, what it means for the operator, and whether " +
"they need to act"),
"resolved": str("the one short line said when it is over, e.g. backups run again on ace"),
"clear": map[string]any{"type": "boolean", "description": "it is over: the message is edited to say so"},
},
Run: func(a map[string]any) (any, error) {
clear, _ := a["clear"].(bool)
return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear,
strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved"))
}},
{Name: "messenger_status",
Description: "Whether the operator can be reached, and why not: each channel — can it send, what it lacks " +
"(the Telegram bot token and chat id, the desktop machines), its last error, how many it sent in the " +
"last hour against the cap, how many it holds — the open and silenced count, messages not sent yet, " +
"refusals, unreadable events, whether condition events arrive, and the rules it applies. check asks " +
"Telegram who the bot is, sending nothing.",
Input: map[string]any{"check": map[string]any{"type": "boolean", "description": "also ask Telegram whether the token works"}},
Run: func(a map[string]any) (any, error) {
st := h.Status(l.get())
if check, _ := a["check"].(bool); check {
if t, ok := h.Telegram.(*Telegram); ok {
who, err := t.Who()
if err != nil {
return map[string]any{"status": st, "telegram_check": "failed: " + err.Error()}, nil
}
return map[string]any{"status": st, "telegram_check": "the token works: the bot is @" + who}, nil
}
}
return st, nil
}},
{Name: "messenger_recent",
Description: "The recent sends — each message, digest, edit and failure on each channel, newest first — and the refusals.",
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many (default 20)"}},
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 20)), nil }},
{Name: "messenger_test",
Description: "Send a test message now, to telegram, desktop or both (default both), through the content rule " +
"and the cap: proves a channel reaches the operator. Answers per channel: sent, or why not.",
Input: map[string]any{
"channel": map[string]any{"type": "string", "enum": []string{"telegram", "desktop", "both"}},
"text": str("the words (default: a line saying it is a test)"),
},
Run: func(a map[string]any) (any, error) { return h.Test(strArg(a, "channel"), strArg(a, "text")), nil }},
{Name: "messenger_check",
Description: "Would these words be allowed to leave the mesh? Runs the content rule — no address, path or " +
"secret — and answers allowed, or the class of what it carried. Sends nothing.",
Input: map[string]any{"text": str("the words to judge")},
Run: func(a map[string]any) (any, error) {
if r, ok := Check(strArg(a, "text")); !ok {
return map[string]any{"allowed": false, "class": r.Class, "carried": r.What}, nil
}
return map[string]any{"allowed": true}, nil
}},
}
}