Files
mesh-catalog/modules/messenger/cmd/messenger/desktop.go
T
jochen f00b5a19b1
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 delivered
mesh/delivery-group group feat/plain-notifications delivered: every member is delivered
Say what the operator needs to do, keep quiet what needs nothing, and take answers from the desk
The operator could not tell from a notification whether to act, nor act without
opening a session. Each message now opens with a verdict, a warning that needs
nothing is not sent, and a notification offers answers the messenger performs
through their seat verbs; dunst's send gains actions and says the chosen one
(hq ADR 0253).
2026-10-08 13:57:33 +02:00

185 lines
5.3 KiB
Go

package main
// The desktop notifier (novox/hq to-be 45 §5, ADR 0208): the `node-notifier` seat's `send` verb on
// the machine the operator is at, asked through the mesh. Nothing new runs on that machine — the
// seat's holder (dunst) already answers `send` in the operator's session, and answers with an error
// when no session is there. That error is what "the operator's session is there" is read from until
// presence is decided (research 028 Q4): a machine whose notifier answers has a session.
//
// Which machines to try is the setting desktop-machines, in order. None given: the channel is not
// configured, the status says so, and a warning goes to Telegram.
import (
"encoding/json"
"errors"
"fmt"
"html"
"strings"
"sync"
)
// Asker calls a tool through the mesh, as stdio.Ask does.
type Asker func(key string, body any) (json.RawMessage, error)
// Desktop sends to the notifier of each configured machine.
type Desktop struct {
Machines func() []string
Ask Asker
mu sync.Mutex
last map[string]string // machine -> the last error, or "" after a success
}
func (d *Desktop) Name() string { return "desktop" }
func (d *Desktop) CanEdit() bool { return true }
// SilentEdit: replacing a notification shows it again.
func (d *Desktop) SilentEdit() bool { return false }
func (d *Desktop) Ready() error {
if len(d.Machines()) == 0 {
return errors.New("no machine to show it on: the setting desktop-machines is not given " +
"(`settings` for messenger with {\"desktop-machines\": [\"<the machine the operator sits at>\"]})")
}
return nil
}
// Send shows the message on every configured machine that has a session, and answers
// `<machine>=<id>` for each one that took it. It fails only when none did.
func (d *Desktop) Send(m Message) (string, error) {
return d.show(m, nil)
}
// Edit replaces the notification shown before on each machine that showed it.
func (d *Desktop) Edit(id string, m Message) error {
shown := map[string]int{}
for _, part := range strings.Split(id, ",") {
machine, n, ok := strings.Cut(part, "=")
var i int
if ok {
if _, err := fmt.Sscan(n, &i); err == nil {
shown[machine] = i
}
}
}
if len(shown) == 0 {
return errors.New("no notification on record to replace")
}
_, err := d.show(m, shown)
return err
}
func (d *Desktop) show(m Message, replace map[string]int) (string, error) {
if err := d.Ready(); err != nil {
return "", err
}
urgency := "normal"
switch {
case m.Quiet:
urgency = "low"
case m.Urgent:
urgency = "critical"
}
var took []string
var failed []string
for _, machine := range d.Machines() {
if replace != nil {
if _, shown := replace[machine]; !shown {
continue
}
}
args := map[string]any{
"summary": m.Title,
"body": html.EscapeString(m.Body),
"urgency": urgency,
"app_name": "mesh",
}
if replace != nil {
args["replace_id"] = replace[machine]
}
if len(m.Actions) > 0 {
// The answers the notification offers (node-notifier's actions): the notifier hands the chosen
// token back as an event, which the holder takes (answers.go).
args["actions"] = m.Actions
}
raw, err := d.Ask("seat:node-notifier.send@"+machine, args)
if err == nil {
var id int
id, err = notificationID(raw)
if err == nil {
took = append(took, fmt.Sprintf("%s=%d", machine, id))
}
}
d.note(machine, err)
if err != nil {
failed = append(failed, machine+": "+err.Error())
}
}
if len(took) == 0 {
return "", errors.New("no machine showed it — " + strings.Join(failed, "; "))
}
return strings.Join(took, ","), nil
}
func (d *Desktop) note(machine string, err error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.last == nil {
d.last = map[string]string{}
}
if err != nil {
d.last[machine] = err.Error()
} else {
d.last[machine] = ""
}
}
// Machines' last answers, for the status: "" is a machine that took the last message shown to it.
func (d *Desktop) LastAnswers() map[string]string {
d.mu.Lock()
defer d.mu.Unlock()
out := map[string]string{}
for k, v := range d.last {
out[k] = v
}
return out
}
// showOne shows a message on one machine only: an answer's outcome, or details, where it was asked.
func (d *Desktop) showOne(machine string, m Message) (string, error) {
only := &Desktop{Machines: func() []string { return []string{machine} }, Ask: d.Ask}
id, err := only.Send(m)
d.note(machine, err)
return id, err
}
// notificationID reads the notifier's answer, {"id": N}, whether the runtime hands it over bare or in
// the tool reply's text content.
func notificationID(raw json.RawMessage) (int, error) {
var direct struct {
ID *int `json:"id"`
}
if json.Unmarshal(raw, &direct) == nil && direct.ID != nil {
return *direct.ID, nil
}
var wrapped struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Content) > 0 {
if wrapped.IsError {
return 0, errors.New(wrapped.Content[0].Text)
}
if json.Unmarshal([]byte(wrapped.Content[0].Text), &direct) == nil && direct.ID != nil {
return *direct.ID, nil
}
}
var s string
if json.Unmarshal(raw, &s) == nil && json.Unmarshal([]byte(s), &direct) == nil && direct.ID != nil {
return *direct.ID, nil
}
return 0, fmt.Errorf("the notifier answered no id: %.80s", string(raw))
}