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
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).
185 lines
5.3 KiB
Go
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))
|
|
}
|