The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold, no summary. A settings proposal was the same wall of text, its values masked so the approval was blind. - messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each answer does, the expiry, where else it is answered and the asker's Details travel as the message's note (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram · <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone (from the earlier work on issue 383, #178). - telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message shown before is still compared by the digest it was kept under. - desk-channel: the note follows the body. - mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is composed again as it was asked (form), so its warrant still holds. - claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the module's), its text in the Details; a question's context is at most 300 characters and its recommendation one point. Older proposals and questions keep their form. The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and asks.Glance.
514 lines
18 KiB
Go
514 lines
18 KiB
Go
package main
|
|
|
|
// The desk: kind `desktop` of the `channel` and `intake` benches (novox/hq ADR 0258, ADR 0259). It shows the
|
|
// router's messages on the notifier of each machine the operator sits at (the `node-notifier` seat, dunst)
|
|
// and says which button was chosen there. It is a channel like any other: the router judges every answer.
|
|
//
|
|
// - **It proves nothing about who clicked.** Any process of the operator's account can choose a
|
|
// notification's action, so its claim declares no `verified-sender`: the router offers here only
|
|
// acknowledgements, and says where an approval can be given.
|
|
// - **An answer is bound to the machines it was shown on**: a click on another machine is refused here, and
|
|
// said there. A message with buttons stays until it is dismissed.
|
|
// - **What it remembers is never shown again** (issue 384). Its memory — which notification a handle has on
|
|
// each machine — serves only to replace or edit a message in place. When the notifier's bundle started again
|
|
// on a machine (`dunst.started`), the notifications stay on screen but the answers they waited for are lost:
|
|
// the desk says in its standing when that happened (`answers-lost-at`), and the router, which alone knows
|
|
// what is still open and unanswered, says that again under its handle — replacing the notification in place,
|
|
// so its buttons work again. Nothing resolved, silenced or cancelled comes back.
|
|
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
|
|
// status. A message no machine took is said to the router as failed, and tried again by it.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
// Kind is this channel's kind on both benches.
|
|
const Kind = "desktop"
|
|
|
|
// Account is the account this module runs as, in a runtime of its own (runs-as).
|
|
const Account = "desk-channel"
|
|
|
|
// PausedFresh is how old a notifier's word on whether it is paused may be: it says it every two minutes, so
|
|
// two missed is a notifier stopped or a machine away.
|
|
const PausedFresh = 5 * time.Minute
|
|
|
|
// pauseSeen is what a machine's notifier last said of its pause, and when.
|
|
type pauseSeen struct {
|
|
paused bool
|
|
at time.Time
|
|
}
|
|
|
|
// StandingEvery is how often the channel says whether it can send, at least.
|
|
const StandingEvery = 5 * time.Minute
|
|
|
|
// Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost
|
|
// the answers its notifications waited for (its bundle started again). The router then says again what is
|
|
// still open and unanswered (issue 384). It belongs in asks.Standing once the SDK carries it; until then the
|
|
// field travels as JSON the router reads under the same name. Zero (as "0001-01-01T00:00:00Z") until then.
|
|
type Standing struct {
|
|
asks.Standing
|
|
AnswersLostAt time.Time `json:"answers-lost-at"`
|
|
}
|
|
|
|
// Shown is one of the router's messages as the desk shows it: the message, and its notification on each
|
|
// machine.
|
|
type Shown struct {
|
|
Message asks.Message `json:"message"`
|
|
On map[string]int `json:"on"`
|
|
Sent time.Time `json:"sent"`
|
|
}
|
|
|
|
// Store keeps what is shown, by the router's handle.
|
|
type Store interface {
|
|
Get(handle string) (*Shown, error)
|
|
Put(handle string, s Shown) error
|
|
Handles() ([]string, error)
|
|
}
|
|
|
|
// Desk is the channel's whole working.
|
|
type Desk struct {
|
|
Machines func() []string
|
|
// Notify calls one machine's notifier: `seat:node-notifier.send@<machine>`.
|
|
Notify func(machine string, args map[string]any) (json.RawMessage, error)
|
|
Store Store
|
|
Publish func(subject string, body any, id string) error
|
|
Now func() time.Time
|
|
Logf func(string, ...any)
|
|
|
|
mu sync.Mutex
|
|
last map[string]string // machine -> its last error, "" after a success
|
|
clickedOn map[string]string // a choice's id -> the machine it was made on, for the router's reply
|
|
said Standing
|
|
saidAt time.Time
|
|
acted map[string]bool
|
|
pause map[string]pauseSeen
|
|
// lost is when each machine's notifier last started again: the answers its notifications waited for are lost.
|
|
lost map[string]time.Time
|
|
}
|
|
|
|
func (d *Desk) init() {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.last == nil {
|
|
d.last, d.clickedOn, d.acted = map[string]string{}, map[string]string{}, map[string]bool{}
|
|
}
|
|
if d.pause == nil {
|
|
d.pause = map[string]pauseSeen{}
|
|
}
|
|
if d.lost == nil {
|
|
d.lost = map[string]time.Time{}
|
|
}
|
|
if d.Now == nil {
|
|
d.Now = time.Now
|
|
}
|
|
if d.Logf == nil {
|
|
d.Logf = func(string, ...any) {}
|
|
}
|
|
}
|
|
|
|
func (d *Desk) note(machine string, err error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if err != nil {
|
|
d.last[machine] = err.Error()
|
|
} else {
|
|
d.last[machine] = ""
|
|
}
|
|
}
|
|
|
|
// Take does one piece of the desk's work. It returns an error only to have the work offered again.
|
|
func (d *Desk) Take(subject string, body json.RawMessage) error {
|
|
d.init()
|
|
parts := strings.Split(subject, ".")
|
|
if len(parts) != 6 || parts[5] != Kind {
|
|
d.Logf("[desk-channel] work on %s is not this kind's; taken and dropped", subject)
|
|
return nil
|
|
}
|
|
var m asks.Message
|
|
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
|
|
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
|
|
return nil
|
|
}
|
|
machines := d.Machines()
|
|
if m.Secret {
|
|
// Shown only where the notifier said lately it is not paused (the review of 2026-10-09): one held back
|
|
// would appear later, to whoever is at the screen then.
|
|
seeing, why := d.seeing()
|
|
if len(seeing) == 0 {
|
|
f := asks.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
|
|
return d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
|
|
}
|
|
machines = seeing
|
|
}
|
|
if m.Reply != "" && !m.Secret {
|
|
// An answer to a click is shown where the click was made.
|
|
d.mu.Lock()
|
|
on, ok := d.clickedOn[m.Reply]
|
|
d.mu.Unlock()
|
|
if ok {
|
|
machines = []string{on}
|
|
}
|
|
}
|
|
// **One notification per message** (issue 373): an edit, and a message said again under a handle already
|
|
// shown (a reminder, an escalation), replace the notification it had on each machine — dunst's replace id —
|
|
// so a problem is one notification updated in place, never one more beside it. An edit stays quiet where
|
|
// it was shown; a message said again is shown anew where it was not.
|
|
edit := parts[4] == asks.Edit
|
|
var before *Shown
|
|
switch {
|
|
case edit:
|
|
var err error
|
|
if before, err = d.Store.Get(m.Handle); err != nil {
|
|
return err
|
|
}
|
|
case !m.Secret:
|
|
var err error
|
|
if before, err = d.Store.Get(m.Handle); err != nil {
|
|
d.Logf("[desk-channel] what is shown under this handle could not be read (%v): shown as a new message", err)
|
|
before = nil
|
|
}
|
|
}
|
|
s := Shown{Message: m, On: map[string]int{}, Sent: d.Now()}
|
|
var failed []string
|
|
shown := 0
|
|
for _, machine := range machines {
|
|
replace := 0
|
|
if before != nil {
|
|
replace = before.On[machine]
|
|
if edit && replace == 0 {
|
|
continue // an edit goes where the message was shown, and nowhere new
|
|
}
|
|
}
|
|
id, err := d.show(machine, m, replace)
|
|
if err != nil {
|
|
failed = append(failed, machine+": "+err.Error())
|
|
if replace != 0 {
|
|
// Its notification is still there: kept, so a later edit or clearing still reaches it.
|
|
s.On[machine] = replace
|
|
}
|
|
continue
|
|
}
|
|
s.On[machine] = id
|
|
shown++
|
|
}
|
|
if shown == 0 {
|
|
why := "no machine showed it"
|
|
if len(failed) > 0 {
|
|
why += " — " + strings.Join(failed, "; ")
|
|
}
|
|
if len(machines) == 0 {
|
|
why = "no machine to show it on: the setting desktop-machines names none"
|
|
}
|
|
f := asks.Failed{Handle: m.Handle, Why: why, At: d.Now()}
|
|
if err := d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if m.Secret {
|
|
// Shown once and kept nowhere of this module's (a link's code): no edit or re-showing reaches it.
|
|
return nil
|
|
}
|
|
if err := d.Store.Put(m.Handle, s); err != nil {
|
|
d.Logf("[desk-channel] shown, and what was shown could not be kept: an edit of it will not reach it: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// show puts one message on one machine's notifier, in place of `replace` when it names one.
|
|
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
|
|
urgency := "normal"
|
|
switch {
|
|
case m.Urgent:
|
|
urgency = "critical"
|
|
case m.Silent:
|
|
urgency = "low"
|
|
}
|
|
// The note — who asked, what each answer does, the expiry — follows the body after a blank line: a
|
|
// notification has no smaller text, and the note is never left out (novox/hq issue 639).
|
|
text := strings.TrimSpace(m.Body)
|
|
if note := strings.TrimSpace(m.Note); note != "" {
|
|
text = strings.TrimSpace(text + "\n\n" + note)
|
|
}
|
|
args := map[string]any{"summary": m.Title, "body": html.EscapeString(text), "urgency": urgency, "app_name": "mesh"}
|
|
if replace != 0 {
|
|
args["replace_id"] = replace
|
|
}
|
|
if m.Secret {
|
|
// Transient and secret: the notifier keeps it out of the history every caller of the verbs reads.
|
|
args["secret"] = true
|
|
args["transient"] = true
|
|
}
|
|
if len(m.Buttons) > 0 {
|
|
var actions []map[string]string
|
|
for _, b := range m.Buttons {
|
|
actions = append(actions, map[string]string{"token": b.Ticket, "label": b.Label})
|
|
}
|
|
// Answers stay until the operator dismisses them: a warning's answers must not die in ten seconds.
|
|
args["expire_ms"] = 0
|
|
args["actions"] = actions
|
|
}
|
|
raw, err := d.Notify(machine, args)
|
|
if err == nil {
|
|
var id int
|
|
if id, err = notificationID(raw); err == nil {
|
|
d.note(machine, nil)
|
|
return id, nil
|
|
}
|
|
}
|
|
d.note(machine, err)
|
|
return 0, err
|
|
}
|
|
|
|
// Chosen takes a button chosen on a machine's notifier (dunst's `action-chosen`): said to the router as this
|
|
// kind's choice when the message it belongs to was shown on that machine; refused there otherwise. The
|
|
// machine is the event's own node, said by the runtime; the same event is acted on once.
|
|
func (d *Desk) Chosen(event, machine, ticket string) {
|
|
d.init()
|
|
if d.again(event) {
|
|
return
|
|
}
|
|
handle, shown := d.find(ticket)
|
|
if shown == nil {
|
|
_, _ = d.show(machine, asks.Message{Title: "That answer was not taken",
|
|
Body: "It belongs to no message shown here any more. Nothing was done.", Silent: true}, 0)
|
|
return
|
|
}
|
|
if _, here := shown.On[machine]; !here {
|
|
_, _ = d.show(machine, asks.Message{Title: "That answer was not taken",
|
|
Body: "It was not shown on " + machine + ". Nothing was done.", Silent: true}, 0)
|
|
return
|
|
}
|
|
id := "desk-" + event
|
|
if event == "" {
|
|
id = "desk-" + ticket + "-" + strconv.FormatInt(d.Now().UnixNano(), 10)
|
|
}
|
|
d.mu.Lock()
|
|
if len(d.clickedOn) > 500 {
|
|
d.clickedOn = map[string]string{}
|
|
}
|
|
d.clickedOn[id] = machine
|
|
d.mu.Unlock()
|
|
c := asks.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
|
|
Sender: asks.Sender{Identity: machine, Display: machine, Authenticated: false}}
|
|
if err := d.Publish(asks.IntakeSubject(asks.Chosen, Kind), c, id); err != nil {
|
|
d.Logf("[desk-channel] a choice could not be said to the router: %v", err)
|
|
_, _ = d.show(machine, asks.Message{Title: "That answer could not be taken now",
|
|
Body: "The mesh did not take it. Try again in a minute.", Silent: true}, 0)
|
|
}
|
|
}
|
|
|
|
func (d *Desk) find(ticket string) (string, *Shown) {
|
|
handles, err := d.Store.Handles()
|
|
if err != nil {
|
|
return "", nil
|
|
}
|
|
for _, h := range handles {
|
|
s, err := d.Store.Get(h)
|
|
if err != nil || s == nil {
|
|
continue
|
|
}
|
|
for _, b := range s.Message.Buttons {
|
|
if b.Ticket == ticket {
|
|
return h, s
|
|
}
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// again says whether an event was acted on before (a redelivery), and marks it acted on.
|
|
func (d *Desk) again(event string) bool {
|
|
if event == "" {
|
|
return false
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
done := d.acted[event]
|
|
if len(d.acted) > 2000 {
|
|
d.acted = map[string]bool{}
|
|
}
|
|
d.acted[event] = true
|
|
return done
|
|
}
|
|
|
|
// Started takes a notifier's word that its bundle started again on a machine (dunst's event `started`): the
|
|
// notifications stay on screen, and the answers they waited for are lost. Nothing is shown from memory (issue
|
|
// 384: nine old popups, resolved and silenced ones among them, shown again from it). The notification ids
|
|
// are kept, so what the router says again replaces each notification in place, and the standing says when
|
|
// the answers were lost; the router alone knows what is still open and unanswered. It answers how many
|
|
// messages with buttons that machine shows.
|
|
func (d *Desk) Started(event, machine string) int {
|
|
d.init()
|
|
if d.again(event) {
|
|
return 0
|
|
}
|
|
d.mu.Lock()
|
|
d.lost[machine] = d.Now()
|
|
d.mu.Unlock()
|
|
n := 0
|
|
if handles, err := d.Store.Handles(); err == nil {
|
|
for _, h := range handles {
|
|
if s, err := d.Store.Get(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
|
|
if _, here := s.On[machine]; here {
|
|
n++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
d.Logf("[desk-channel] the notifier on %s started again: the answers of %d message(s) shown there are lost; none shown "+
|
|
"from memory, the router says again what is still open", machine, n)
|
|
d.SayStanding()
|
|
return n
|
|
}
|
|
|
|
// Paused takes a notifier's word on whether it is paused (dunst's event `paused`), from the event's own node.
|
|
func (d *Desk) Paused(machine string, paused bool) {
|
|
d.init()
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.pause[machine] = pauseSeen{paused: paused, at: d.Now()}
|
|
}
|
|
|
|
// seeing is the named machines whose notifier said lately that it is not paused, and why none is when none is.
|
|
func (d *Desk) seeing() ([]string, string) {
|
|
machines := d.Machines()
|
|
if len(machines) == 0 {
|
|
return nil, "no machine to show it on: the setting desktop-machines names none"
|
|
}
|
|
now := d.Now()
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
var out, not []string
|
|
for _, m := range machines {
|
|
p, heard := d.pause[m]
|
|
switch {
|
|
case !heard || now.Sub(p.at) > PausedFresh:
|
|
not = append(not, m+" (its notifier not heard from lately)")
|
|
case p.paused:
|
|
not = append(not, m+" (notifications paused)")
|
|
default:
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, "notifications are paused on the desk, or its notifier was not heard from lately, on every " +
|
|
"machine named: " + strings.Join(not, ", ")
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// Standing is whether the desk can send: a named machine whose notifier said lately that it is not paused
|
|
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
|
|
func (d *Desk) Standing() Standing {
|
|
d.init()
|
|
s := Standing{Standing: asks.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
|
|
if _, why := d.seeing(); why != "" {
|
|
s.Why = why
|
|
return s
|
|
}
|
|
s.Ready = true
|
|
return s
|
|
}
|
|
|
|
// answersLostAt is the latest start of a named machine's notifier: zero while none started again since this
|
|
// desk started.
|
|
func (d *Desk) answersLostAt() time.Time {
|
|
machines := d.Machines() // read outside the lock: it reads the settings file
|
|
var at time.Time
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
for _, m := range machines {
|
|
if t := d.lost[m]; t.After(at) {
|
|
at = t
|
|
}
|
|
}
|
|
return at
|
|
}
|
|
|
|
// SayStanding says the standing when it changed, or every StandingEvery.
|
|
func (d *Desk) SayStanding() {
|
|
s := d.Standing()
|
|
d.mu.Lock()
|
|
same := s.Ready == d.said.Ready && s.Why == d.said.Why && s.AnswersLostAt.Equal(d.said.AnswersLostAt) &&
|
|
s.At.Sub(d.saidAt) < StandingEvery
|
|
d.mu.Unlock()
|
|
if same {
|
|
return
|
|
}
|
|
if err := d.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
|
|
d.Logf("[desk-channel] could not say whether it can send: %v", err)
|
|
return
|
|
}
|
|
d.mu.Lock()
|
|
d.said, d.saidAt = s, s.At
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// Status is the desk's account of itself.
|
|
func (d *Desk) Status() map[string]any {
|
|
s := d.Standing()
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
last := map[string]string{}
|
|
for k, v := range d.last {
|
|
if v == "" {
|
|
v = "took the last message"
|
|
}
|
|
last[k] = v
|
|
}
|
|
out := map[string]any{"kind": Kind, "can_send": s.Ready, "machines": d.Machines(), "machines_last": last,
|
|
"rules": []string{
|
|
"it shows the router's messages on the notifier of each machine named in desktop-machines, and says which button was chosen there; the router judges every answer (novox/hq ADR 0259)",
|
|
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
|
|
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
|
|
"what it remembers is never shown again: when a notifier started again, the answers its notifications waited for are lost; the desk says when in its standing (answers-lost-at), and the router says again what is still open and unanswered, replacing those notifications in place — nothing resolved, silenced or cancelled comes back (issue 384)",
|
|
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
|
|
}}
|
|
if s.Why != "" {
|
|
out["why_not"] = s.Why
|
|
}
|
|
if !s.AnswersLostAt.IsZero() {
|
|
started := map[string]string{}
|
|
for m, at := range d.lost {
|
|
started[m] = at.Format(time.RFC3339)
|
|
}
|
|
out["notifier_started"] = started
|
|
}
|
|
return out
|
|
}
|
|
|
|
// notificationID reads the notifier's answer, {"id": N}, bare or in a tool reply's text.
|
|
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
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("the notifier answered no id: %.80s", string(raw))
|
|
}
|