Merge pull request 'telegram and desk-channel: channels of their own (hq ADR 0259)' (#134) from feat/asks-answered-on-any-channel-channels into main
This commit was merged in pull request #134.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# desk-channel
|
||||
|
||||
The desk as a channel of the operator's conversation: kind `desktop` of the `channel` and `intake` benches the
|
||||
router (`messenger`) declares (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), and says which button was chosen there.
|
||||
|
||||
- **It proves nothing about who clicked.** Any process of the operator's account can choose a notification's
|
||||
action (`dunstctl action`), so its claim declares no `verified-sender`: the router offers here only
|
||||
acknowledgements (Silence for a week, Details) and says in the words where an approval can be given.
|
||||
- **An answer is taken only on a machine its message was shown on**, and once; a refused click is said where it
|
||||
was made. A message with buttons stays until it is dismissed.
|
||||
- **A notifier that started again** (`dunst.started`) is shown again every open message with buttons it showed.
|
||||
- A message no machine took is said to the router as failed; the router tries it again.
|
||||
|
||||
## What the operator gives
|
||||
|
||||
The machines to show on, as a setting: `settings` for `desk-channel` with
|
||||
`{"desktop-machines": ["<the machine the operator sits at>", …]}`. Until then it says it cannot send, and the
|
||||
router sends warnings to the channel that reaches the operator away.
|
||||
|
||||
## Where it runs
|
||||
|
||||
Held once for the mesh, on the same machine as the router, which is where it is assigned. **It runs as the
|
||||
account `desk-channel`, in a runtime of its own, on a bus account of its own** (`runs-as`), as telegram does:
|
||||
what it shows and the answers it says are out of reach of the operator's account, which agents run as. It asks
|
||||
each machine's notifier through the mesh: nothing new runs on the machines.
|
||||
|
||||
## A link's code (the review of 2026-10-09)
|
||||
|
||||
- **It is shown only where it is seen.** Each machine's notifier says whether it is paused (`dunst.paused`, at
|
||||
least every two minutes). A message the router marks secret is shown only on a named machine heard unpaused in
|
||||
the last five minutes; while there is none, it is said to the router as failed and the desk says it cannot
|
||||
send, so the router starts no link. A code held by a paused notifier would appear later, to whoever is at the
|
||||
screen then.
|
||||
- **It is kept nowhere.** Not in this module's state, and sent `secret` to the notifier, which sends it
|
||||
transient in the category `mesh.secret`: dunst's rules keep it out of its history, and
|
||||
`node-notifier.history`, which every caller of the mesh's verbs may read, leaves it out.
|
||||
- **What remains**: while it is on screen, a program of the operator's account on that X display can read it
|
||||
(X11 isolates no client from another). It is shown for as long as the router's code lives.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `desk_channel_status` | whether it can show messages, the machines and each one's last answer, and its rules |
|
||||
@@ -0,0 +1,441 @@
|
||||
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.
|
||||
// - **A notifier that started again lost the answers it waited for**: every open message with buttons shown
|
||||
// on that machine is shown there again.
|
||||
// - **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"
|
||||
"sort"
|
||||
"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
|
||||
|
||||
// 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 asks.Standing
|
||||
saidAt time.Time
|
||||
acted map[string]bool
|
||||
pause map[string]pauseSeen
|
||||
}
|
||||
|
||||
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.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}
|
||||
}
|
||||
}
|
||||
var before *Shown
|
||||
if parts[4] == asks.Edit {
|
||||
var err error
|
||||
if before, err = d.Store.Get(m.Handle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s := Shown{Message: m, On: map[string]int{}, Sent: d.Now()}
|
||||
var failed []string
|
||||
for _, machine := range machines {
|
||||
replace := 0
|
||||
if before != nil {
|
||||
replace = before.On[machine]
|
||||
if 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())
|
||||
continue
|
||||
}
|
||||
s.On[machine] = id
|
||||
}
|
||||
if len(s.On) == 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"
|
||||
}
|
||||
args := map[string]any{"summary": m.Title, "body": html.EscapeString(m.Body), "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()
|
||||
d.mu.Lock()
|
||||
done := d.acted[event]
|
||||
if event != "" {
|
||||
d.acted[event] = true
|
||||
}
|
||||
d.mu.Unlock()
|
||||
if done {
|
||||
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
|
||||
}
|
||||
|
||||
// Started shows again, on a machine whose notifier started again, every message with buttons shown there.
|
||||
func (d *Desk) Started(machine string) int {
|
||||
d.init()
|
||||
handles, err := d.Store.Handles()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
sort.Strings(handles)
|
||||
n := 0
|
||||
for _, h := range handles {
|
||||
s, err := d.Store.Get(h)
|
||||
if err != nil || s == nil || len(s.Message.Buttons) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, here := s.On[machine]; !here {
|
||||
continue
|
||||
}
|
||||
id, err := d.show(machine, s.Message, 0)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.On[machine] = id
|
||||
_ = d.Store.Put(h, *s)
|
||||
n++
|
||||
}
|
||||
if n > 0 {
|
||||
d.Logf("[desk-channel] the notifier on %s started again: %d message(s) with answers shown there again", machine, n)
|
||||
}
|
||||
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() asks.Standing {
|
||||
d.init()
|
||||
s := asks.Standing{At: d.Now()}
|
||||
if _, why := d.seeing(); why != "" {
|
||||
s.Why = why
|
||||
return s
|
||||
}
|
||||
s.Ready = true
|
||||
return s
|
||||
}
|
||||
|
||||
// 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.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",
|
||||
"a notifier that started again is shown again every open message with buttons it showed",
|
||||
"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
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
type memShown map[string]Shown
|
||||
|
||||
func (m memShown) Get(h string) (*Shown, error) {
|
||||
s, ok := m[h]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
func (m memShown) Put(h string, s Shown) error { m[h] = s; return nil }
|
||||
func (m memShown) Handles() ([]string, error) {
|
||||
var out []string
|
||||
for h := range m {
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type notified struct {
|
||||
machine string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func newDesk(t *testing.T, machines ...string) (*Desk, *[]notified, map[string][]json.RawMessage, memShown) {
|
||||
t.Helper()
|
||||
var shown []notified
|
||||
said := map[string][]json.RawMessage{}
|
||||
store := memShown{}
|
||||
n := 0
|
||||
d := &Desk{Machines: func() []string { return machines }, Store: store, Logf: t.Logf,
|
||||
Now: func() time.Time { return time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC) },
|
||||
Notify: func(machine string, args map[string]any) (json.RawMessage, error) {
|
||||
if machine == "away-machine" {
|
||||
return nil, errors.New("no session answers")
|
||||
}
|
||||
n++
|
||||
shown = append(shown, notified{machine, args})
|
||||
raw, _ := json.Marshal(map[string]int{"id": n})
|
||||
return raw, nil
|
||||
},
|
||||
Publish: func(subject string, body any, id string) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
said[subject] = append(said[subject], raw)
|
||||
return nil
|
||||
}}
|
||||
d.init()
|
||||
return d, &shown, said, store
|
||||
}
|
||||
|
||||
func take(t *testing.T, d *Desk, verb string, m asks.Message) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(m)
|
||||
if err := d.Take(asks.ChannelSubject(verb, Kind), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259: the desk shows the router's message with its tickets as the notifier's actions, and
|
||||
// says a click as a choice the router judges — only on a machine the message was shown on.
|
||||
func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
|
||||
d, shown, said, _ := newDesk(t, "laptop", "away-machine")
|
||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
|
||||
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["expire_ms"] != 0 ||
|
||||
(*shown)[0].args["body"] != "Needs you: <mend> them." {
|
||||
t.Fatalf("shown %+v", *shown)
|
||||
}
|
||||
d.Chosen("e1", "laptop", "t1")
|
||||
d.Chosen("e1", "laptop", "t1") // a redelivery
|
||||
got := said[asks.IntakeSubject(asks.Chosen, Kind)]
|
||||
var c asks.Choice
|
||||
if len(got) != 1 || json.Unmarshal(got[0], &c) != nil || c.Ticket != "t1" || c.Handle != "m1" ||
|
||||
c.Sender.Identity != "laptop" || c.Sender.Authenticated {
|
||||
t.Fatalf("said %s", got)
|
||||
}
|
||||
// Elsewhere it is refused, there, and nothing is said to the router.
|
||||
d.Chosen("e2", "workstation", "t1")
|
||||
d.Chosen("e3", "laptop", "forged")
|
||||
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
|
||||
t.Error("a click not shown there was said")
|
||||
}
|
||||
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "That answer was not taken" {
|
||||
t.Errorf("the refusal was not shown where it was chosen: %+v", last)
|
||||
}
|
||||
// The router's reply to the click is shown on the machine it was made on.
|
||||
take(t, d, asks.Send, asks.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
|
||||
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "Details: 3 failed services" {
|
||||
t.Errorf("%+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEditReplacesTheNotificationAndANotifierThatStartedAgainIsShownItsQuestions(t *testing.T) {
|
||||
d, shown, _, store := newDesk(t, "laptop")
|
||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
take(t, d, asks.Edit, asks.Message{Handle: "m1", Title: "Silence for a week: chosen"})
|
||||
if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil {
|
||||
t.Errorf("the edit: %+v", last.args)
|
||||
}
|
||||
take(t, d, asks.Show, asks.Message{Handle: "m2", Title: "q2", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t2"}}})
|
||||
before := len(*shown)
|
||||
if n := d.Started("laptop"); n != 1 || len(*shown) != before+1 {
|
||||
t.Errorf("shown again: %d", n)
|
||||
}
|
||||
if store["m2"].On["laptop"] != len(*shown) {
|
||||
t.Errorf("the new notification is not kept: %+v", store["m2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) {
|
||||
d, _, said, _ := newDesk(t, "away-machine")
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "x"})
|
||||
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
|
||||
strings.Contains(string(f[0]), `"permanent":true`) {
|
||||
t.Errorf("%s", f)
|
||||
}
|
||||
none, _, _, _ := newDesk(t)
|
||||
if s := none.Standing(); s.Ready || !strings.Contains(s.Why, "desktop-machines names none") {
|
||||
t.Errorf("%+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// It claims the desk's kind, and promises nothing that would let it approve.
|
||||
func TestItPromisesNoVerifiedSender(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Claims []struct {
|
||||
Name, Kind string
|
||||
Capabilities []string
|
||||
}
|
||||
Consumes []string
|
||||
}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if len(m.Claims) != 2 || m.Claims[0].Kind != Kind || m.Claims[1].Kind != Kind {
|
||||
t.Fatalf("%+v", m.Claims)
|
||||
}
|
||||
for _, c := range m.Claims[0].Capabilities {
|
||||
if c == "verified-sender" || c == "reaches-away" {
|
||||
t.Errorf("the desk promises %s", c)
|
||||
}
|
||||
}
|
||||
if strings.Join(m.Consumes, ",") != AnswerEvent+","+StartedEvent+","+PausedEvent {
|
||||
t.Errorf("consumes %v", m.Consumes)
|
||||
}
|
||||
}
|
||||
|
||||
// A message the router marks secret (a link's code) is shown and kept nowhere of this module's.
|
||||
func TestASecretMessageIsShownAndNotKept(t *testing.T) {
|
||||
d, shown, _, store := newDesk(t, "laptop")
|
||||
d.Paused("laptop", false)
|
||||
take(t, d, asks.Send, asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
|
||||
if len(*shown) != 1 {
|
||||
t.Fatal("not shown")
|
||||
}
|
||||
if _, kept := store["code"]; kept {
|
||||
t.Error("the code was kept")
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 2026-10-09 (H4, C4): a link's code is shown only on a machine whose notifier said lately that
|
||||
// it is not paused — a code held back by a paused notifier appears later, to whoever is at the screen then —
|
||||
// and is sent secret, so the notifier keeps it out of the history every caller of the verbs reads. While no
|
||||
// named machine is heard unpaused, the desk says it cannot send.
|
||||
func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testing.T) {
|
||||
d, shown, said, store := newDesk(t, "laptop", "desk2")
|
||||
now := d.Now()
|
||||
if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "not heard") {
|
||||
t.Fatalf("ready before any notifier said whether it is paused: %+v", s)
|
||||
}
|
||||
d.Paused("laptop", false)
|
||||
if s := d.Standing(); !s.Ready {
|
||||
t.Fatalf("not ready with laptop heard unpaused: %+v", s)
|
||||
}
|
||||
code := asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
|
||||
take(t, d, asks.Send, code)
|
||||
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true {
|
||||
t.Fatalf("the code was shown as %+v", *shown)
|
||||
}
|
||||
if _, kept := store["code"]; kept {
|
||||
t.Error("the code was kept")
|
||||
}
|
||||
// Paused: not shown anywhere, said as failed, and the desk is not ready.
|
||||
d.Paused("laptop", true)
|
||||
take(t, d, asks.Send, code)
|
||||
if len(*shown) != 1 {
|
||||
t.Fatalf("the code was shown on a paused notifier: %+v", *shown)
|
||||
}
|
||||
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
|
||||
t.Errorf("not said as failed: %s", f)
|
||||
}
|
||||
if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "paused") {
|
||||
t.Errorf("ready while paused: %+v", s)
|
||||
}
|
||||
// Heard unpaused, but longer ago than PausedFresh: not known now.
|
||||
d.Paused("laptop", false)
|
||||
d.Now = func() time.Time { return now.Add(PausedFresh + time.Second) }
|
||||
if s := d.Standing(); s.Ready {
|
||||
t.Errorf("ready on a word older than %s: %+v", PausedFresh, s)
|
||||
}
|
||||
// A machine not named is never one to show on, whatever it says.
|
||||
d.Paused("stranger", false)
|
||||
if s := d.Standing(); s.Ready {
|
||||
t.Error("a machine not named made the desk ready")
|
||||
}
|
||||
// A message that is not secret still goes to every named machine, paused or not.
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m9", Title: "x"})
|
||||
if len(*shown) != 3 {
|
||||
t.Errorf("an ordinary message was held back: %+v", *shown)
|
||||
}
|
||||
}
|
||||
|
||||
// It runs as its own account (the review of 2026-10-09, H4): what it shows and the answers it says are its
|
||||
// own, out of every agent's reach, as telegram's are.
|
||||
func TestItRunsAsItsOwnAccount(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
RunsAs string `json:"runs-as"`
|
||||
Own map[string]string `json:"own-secrets"`
|
||||
Owner string `json:"secrets-owner"`
|
||||
Resources []map[string]any
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.RunsAs != Account || m.Owner != Account || m.Own["broker"] == "" {
|
||||
t.Fatalf("runs as %q, owner %q, secrets %v", m.RunsAs, m.Owner, m.Own)
|
||||
}
|
||||
var account, state, settings bool
|
||||
for _, r := range m.Resources {
|
||||
switch r["id"] {
|
||||
case "account":
|
||||
account = r["type"] == "user" && r["name"] == Account && r["shell"] == "/usr/bin/nologin"
|
||||
case "state":
|
||||
state = r["owner"] == Account && r["mode"] == "0700"
|
||||
case "settings":
|
||||
settings = r["owner"] == Account && r["mode"] == "0600"
|
||||
}
|
||||
}
|
||||
if !account || !state || !settings {
|
||||
t.Errorf("the account %v, its state %v, its settings %v", account, state, settings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// desk-channel: the desk as a channel of the operator's conversation, kind `desktop` of the `channel` and
|
||||
// `intake` benches (novox/hq ADR 0258, ADR 0259). A Go bundle the node's runtime launches. stdout is the MCP
|
||||
// channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// Worker is the work queue of this kind, as the controller names it.
|
||||
const Worker = "SEAT_CHANNEL_DESKTOP_worker"
|
||||
|
||||
// The notifier's events, which the dunst holder of node-notifier emits as its module's own.
|
||||
const (
|
||||
AnswerEvent = "dunst.action-chosen"
|
||||
StartedEvent = "dunst.started"
|
||||
PausedEvent = "dunst.paused"
|
||||
)
|
||||
|
||||
type busStore struct{}
|
||||
|
||||
func (busStore) Get(handle string) (*Shown, error) {
|
||||
e, err := stdio.State("shown").Get(handle)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
var s Shown
|
||||
return &s, json.Unmarshal(e.Value, &s)
|
||||
}
|
||||
func (busStore) Put(handle string, s Shown) error {
|
||||
_, err := stdio.State("shown").Put(handle, s)
|
||||
return err
|
||||
}
|
||||
func (busStore) Handles() ([]string, error) { return stdio.State("shown").Keys() }
|
||||
|
||||
// machines reads the setting desktop-machines at each use, so a change takes effect without a restart.
|
||||
func machines(path string) []string {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var s struct {
|
||||
Machines []string `json:"desktop-machines"`
|
||||
}
|
||||
if json.Unmarshal(raw, &s) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, m := range s.Machines {
|
||||
if m = strings.TrimSpace(m); m != "" {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
settings := os.Getenv("MESH_DESK_SETTINGS")
|
||||
d := &Desk{
|
||||
Machines: func() []string { return machines(settings) },
|
||||
Notify: func(machine string, args map[string]any) (json.RawMessage, error) {
|
||||
return stdio.Ask("seat:node-notifier.send@"+machine, args)
|
||||
},
|
||||
Store: busStore{},
|
||||
Publish: func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) },
|
||||
Logf: logf,
|
||||
}
|
||||
d.init()
|
||||
go run(d)
|
||||
tools := []stdio.Tool{{Name: "desk_channel_status",
|
||||
Description: "Whether the desk can show the operator's messages, and on which machines: the setting " +
|
||||
"desktop-machines, each machine's last answer, and the rules it keeps.",
|
||||
Run: func(map[string]any) (any, error) { return d.Status(), nil }}}
|
||||
if err := stdio.Serve("", tools); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(d *Desk) {
|
||||
time.Sleep(500 * time.Millisecond) // Serve first: the bus is reached through it
|
||||
go func() {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
if err := stdio.SeatTake(Worker, func(w stdio.Work) error { return d.Take(w.Subject, w.Body) }); err == nil {
|
||||
logf("[desk-channel] taking the router's messages for the desk")
|
||||
return
|
||||
} else {
|
||||
logf("[desk-channel] not taking messages yet (%v); asking again in %s", err, wait)
|
||||
}
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
d.SayStanding()
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
}()
|
||||
handle := func(e stdio.Envelope) error {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Machine string `json:"machine"`
|
||||
Paused *bool `json:"paused"`
|
||||
}
|
||||
_ = json.Unmarshal(e.Body, &body)
|
||||
// The machine is the event's own node, said by the runtime: the body's word is only a hint.
|
||||
machine := e.Node
|
||||
if machine == "" {
|
||||
machine = body.Machine
|
||||
}
|
||||
switch e.Key {
|
||||
case StartedEvent:
|
||||
d.Started(machine)
|
||||
case PausedEvent:
|
||||
// Only from the event's own node: a word about another machine is no word at all.
|
||||
if body.Paused != nil && e.Node != "" {
|
||||
d.Paused(e.Node, *body.Paused)
|
||||
d.SayStanding()
|
||||
}
|
||||
case AnswerEvent:
|
||||
if body.Token != "" {
|
||||
d.Chosen(e.Headers["x-event-id"], machine, body.Token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
if err := stdio.Subscribe("dunst.*", handle); err == nil {
|
||||
logf("[desk-channel] listening for the answers chosen on the desk")
|
||||
return
|
||||
} else {
|
||||
logf("[desk-channel] not hearing the desk's answers yet (%v); asking again in %s", err, wait)
|
||||
}
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module deskchannel
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.10
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"module": "desk-channel",
|
||||
"version": "1",
|
||||
"slug": "desk",
|
||||
"runs-as": "desk-channel",
|
||||
"claims": [
|
||||
{
|
||||
"name": "channel",
|
||||
"scope": "mesh",
|
||||
"kind": "desktop",
|
||||
"capabilities": [
|
||||
"deliver",
|
||||
"silent",
|
||||
"loud",
|
||||
"edit",
|
||||
"private",
|
||||
"choice",
|
||||
"exact-render"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "intake",
|
||||
"scope": "mesh",
|
||||
"kind": "desktop"
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"dunst.action-chosen",
|
||||
"dunst.started",
|
||||
"dunst.paused"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:node-notifier.send"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "shown",
|
||||
"ttl-seconds": 2592000
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "desk-channel",
|
||||
"tools": [
|
||||
"desk_channel_status"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "desk-channel",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/desk-channel"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "desk-channel",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "desk-channel",
|
||||
"merge": "json",
|
||||
"content": "{\n \"desktop-machines\": []\n}\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/desk-channel",
|
||||
"binary": "desk-channel",
|
||||
"loads": [
|
||||
"desk-channel"
|
||||
],
|
||||
"env": {
|
||||
"MESH_DESK_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,16 @@ ends the waits it held; it says `started` {machine} once it serves, and the oper
|
||||
open answers there again (novox/hq ADR 0258). `notify-send` prints the id before it waits, also into a
|
||||
pipe (checked on the laptop on 2026-10-08: the id at once, and "Wait timeout expired" when it expired).
|
||||
|
||||
## What is shown once, and whether it would be seen
|
||||
|
||||
- **A secret notification** (`send` with `secret`: a link's code) is sent transient and in the category
|
||||
`mesh.secret`, whatever category was named. The dunstrc's rules keep it, and every transient one, out of
|
||||
dunst's history (`history_ignore`), and `history` leaves out any that got there anyway, counting them as
|
||||
`withheld`: the history is served to every caller of the mesh's verbs, agents among them.
|
||||
- **Whether notifications are paused** is said as this module's event `paused` {paused, machine}: at start,
|
||||
on every change, and every two minutes, read from dunst itself so a pause set by a key counts too. The desk
|
||||
shows a link's code only on a machine it heard is not paused lately.
|
||||
|
||||
## Tools
|
||||
|
||||
Every tool goes over the account's session bus. None needs the screen, and each answers clearly when
|
||||
|
||||
@@ -28,8 +28,16 @@ type Notification struct {
|
||||
Category string
|
||||
ReplaceID int
|
||||
Actions []Choice
|
||||
// Transient asks dunst to keep it out of its history; Secret marks something shown once (a link's code):
|
||||
// transient, and in SecretCategory whatever category was named, which the dunstrc keeps out of the
|
||||
// history and the history verb leaves out (the review of 2026-10-09).
|
||||
Transient bool
|
||||
Secret bool
|
||||
}
|
||||
|
||||
// SecretCategory is the category a secret notification is sent in, and the one the history never shows.
|
||||
const SecretCategory = "mesh.secret"
|
||||
|
||||
func notificationOf(args map[string]any) (Notification, error) {
|
||||
var n Notification
|
||||
var err error
|
||||
@@ -70,10 +78,32 @@ func notificationOf(args map[string]any) (Notification, error) {
|
||||
if n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.Transient, err = boolean(args, "transient"); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.Secret, err = boolean(args, "secret"); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.Secret {
|
||||
n.Transient, n.Category = true, SecretCategory
|
||||
}
|
||||
n.Actions, err = choicesOf(args)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// boolean reads an optional true or false.
|
||||
func boolean(args map[string]any, key string) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return false, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// SendResult is what node-notifier.send answers.
|
||||
type SendResult struct {
|
||||
ID int `json:"id"`
|
||||
@@ -100,6 +130,9 @@ func Send(n Notification) (SendResult, error) {
|
||||
if n.Category != "" {
|
||||
args = append(args, "--category="+n.Category)
|
||||
}
|
||||
if n.Transient {
|
||||
args = append(args, "--transient")
|
||||
}
|
||||
if n.ReplaceID > 0 {
|
||||
args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID))
|
||||
}
|
||||
@@ -187,6 +220,8 @@ type Shown struct {
|
||||
type HistoryResult struct {
|
||||
Total int `json:"total"`
|
||||
Notifications []Shown `json:"notifications"`
|
||||
// Withheld is how many secret notifications the history held and this left out.
|
||||
Withheld int `json:"withheld,omitempty"`
|
||||
}
|
||||
|
||||
// History is dunst's history, newest first.
|
||||
@@ -203,13 +238,18 @@ func parseHistory(raw string, nowMicros int64, limit int) (HistoryResult, error)
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
out := HistoryResult{Total: len(entries), Notifications: []Shown{}}
|
||||
out := HistoryResult{Notifications: []Shown{}}
|
||||
type stamped struct {
|
||||
Shown
|
||||
at int64
|
||||
}
|
||||
var all []stamped
|
||||
for _, e := range entries {
|
||||
if str(e["category"]) == SecretCategory {
|
||||
out.Withheld++
|
||||
continue
|
||||
}
|
||||
out.Total++
|
||||
at := number(e["timestamp"])
|
||||
all = append(all, stamped{Shown{
|
||||
ID: int(number(e["id"])), AppName: str(e["appname"]), Summary: str(e["summary"]), Body: str(e["body"]),
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
func main() {
|
||||
go sayStarted()
|
||||
go watchPaused()
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
@@ -25,7 +26,8 @@ func tools() []stdio.Tool {
|
||||
Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " +
|
||||
"normal, critical), and optionally the sending application's name, an icon, how long it stays and " +
|
||||
"the answers it offers (actions: token and label each). Answers the notification's id; the answer " +
|
||||
"the operator chooses is said later as the event action-chosen {id, token, machine}.",
|
||||
"the operator chooses is said later as the event action-chosen {id, token, machine}. A secret " +
|
||||
"or transient one is kept out of the history.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
@@ -37,6 +39,9 @@ func tools() []stdio.Tool {
|
||||
"expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"},
|
||||
"category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"},
|
||||
"replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"},
|
||||
"transient": map[string]any{"type": "boolean", "description": "keep it out of the history (default false)"},
|
||||
"secret": map[string]any{"type": "boolean", "description": "it carries something shown once, such as a " +
|
||||
"link's code: transient, in the category " + SecretCategory + ", and never in the history (default false)"},
|
||||
"actions": map[string]any{"type": "array", "description": "at most 4 answers the notification offers, " +
|
||||
"each {token, label}; the chosen token is said as the event action-chosen",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||
@@ -55,7 +60,7 @@ func tools() []stdio.Tool {
|
||||
{
|
||||
Name: "node-notifier.history",
|
||||
Description: "The notifications the operator was shown, newest first: id, application, title, " +
|
||||
"body, urgency and how long ago.",
|
||||
"body, urgency and how long ago. A secret one (a link's code) is never shown; withheld counts them.",
|
||||
Input: map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many (default 20, at most 200)"},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
// Whether notifications are paused, said as this module's event `paused` {paused, machine} (the review of
|
||||
// 2026-10-09, C4): at start, on every change, and again every PausedEvery. The desk shows a link's code only on
|
||||
// a machine it heard is not paused lately, and says it cannot send one while none is: a code held back by a
|
||||
// paused notifier would appear later, to whoever is then at the screen. The pause is read from dunst itself
|
||||
// (`dunstctl is-paused`), so one set by a key or by hand is said like one set by dunst_pause.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// PausedEvent is the event's name; PausedEvery how often it is said when nothing changed; PausedLook how
|
||||
// often dunst is asked.
|
||||
PausedEvent = "paused"
|
||||
PausedEvery = 2 * time.Minute
|
||||
PausedLook = 5 * time.Second
|
||||
)
|
||||
|
||||
// pauseWatch says the pause; its two reaches are seams a test replaces.
|
||||
type pauseWatch struct {
|
||||
isPaused func() (bool, error)
|
||||
say func(paused bool) error
|
||||
saidAt time.Time
|
||||
said bool
|
||||
known bool
|
||||
}
|
||||
|
||||
// step looks once, and says it when it changed or was said PausedEvery ago. Nothing is said while dunst
|
||||
// cannot be asked: the desk then hears nothing, and takes the machine as not known.
|
||||
func (w *pauseWatch) step(now time.Time) {
|
||||
paused, err := w.isPaused()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if w.known && paused == w.said && now.Sub(w.saidAt) < PausedEvery {
|
||||
return
|
||||
}
|
||||
if w.say(paused) == nil {
|
||||
w.known, w.said, w.saidAt = true, paused, now
|
||||
}
|
||||
}
|
||||
|
||||
func isPaused() (bool, error) {
|
||||
out, err := dunstctl("is-paused")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch strings.TrimSpace(out) {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("dunstctl is-paused answered %q", out)
|
||||
}
|
||||
|
||||
// watchPaused runs the watch for as long as the bundle runs.
|
||||
func watchPaused() {
|
||||
w := &pauseWatch{isPaused: isPaused, say: func(p bool) error {
|
||||
return emit(PausedEvent, map[string]any{"paused": p, "machine": os.Getenv("MESH_NODE")})
|
||||
}}
|
||||
for {
|
||||
w.step(time.Now())
|
||||
time.Sleep(PausedLook)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A notification carrying something shown once — a link's code — is sent transient and in the category
|
||||
// mesh.secret, whatever category the caller names (the review of 2026-10-09, H4): the dunstrc's rules keep
|
||||
// both out of dunst's history, and the history verb leaves out any that got there anyway, so no agent reads
|
||||
// the code back through node-notifier.history.
|
||||
func TestASecretNotificationIsTransientAndKeptOutOfTheHistory(t *testing.T) {
|
||||
withBus(t)
|
||||
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
n, err := notificationOf(map[string]any{"summary": "Link code", "body": "123 456", "secret": true, "category": "email"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Send(n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
|
||||
for _, want := range []string{"[--transient]", "[--category=" + SecretCategory + "]"} {
|
||||
if !strings.Contains(string(asked), want) {
|
||||
t.Errorf("notify-send was not asked %s: %s", want, asked)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(asked), "--category=email") {
|
||||
t.Error("a secret notification kept the caller's category")
|
||||
}
|
||||
if n, err := notificationOf(map[string]any{"summary": "x", "transient": true}); err != nil || !n.Transient || n.Secret {
|
||||
t.Errorf("transient alone: %+v %v", n, err)
|
||||
}
|
||||
|
||||
// The history leaves a secret one out, and says how many it left out.
|
||||
withSecret := strings.Replace(history, `"category":{"type":"s","data":"device"}`,
|
||||
`"category":{"type":"s","data":"`+SecretCategory+`"}`, 1)
|
||||
got, err := parseHistory(withSecret, 200_000_000, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Notifications) != 1 || got.Notifications[0].ID != 7 || got.Total != 1 || got.Withheld != 1 {
|
||||
t.Fatalf("the history showed a secret notification: %+v", got)
|
||||
}
|
||||
|
||||
// The decided file holds the rules that keep them out of dunst's own history.
|
||||
rc, err := os.ReadFile(filepath.Join("..", "..", "files", "dunstrc"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"category = \"" + SecretCategory + "\"", "match_transient = yes", "history_ignore = yes"} {
|
||||
if !strings.Contains(string(rc), want) {
|
||||
t.Errorf("dunstrc does not say %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whether notifications are paused is said as the event `paused` {paused, machine}: at once, on every change,
|
||||
// and again every PausedEvery, so the desk knows a link's code would not be seen (C4); nothing is said while
|
||||
// dunst cannot be asked.
|
||||
func TestWhetherPausedIsSaidOnChangeAndEveryFewMinutes(t *testing.T) {
|
||||
var said []bool
|
||||
paused, fails := false, false
|
||||
w := pauseWatch{
|
||||
isPaused: func() (bool, error) {
|
||||
if fails {
|
||||
return false, os.ErrNotExist
|
||||
}
|
||||
return paused, nil
|
||||
},
|
||||
say: func(p bool) error { said = append(said, p); return nil },
|
||||
}
|
||||
at := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
|
||||
w.step(at)
|
||||
w.step(at.Add(5 * time.Second))
|
||||
paused = true
|
||||
w.step(at.Add(10 * time.Second))
|
||||
w.step(at.Add(10*time.Second + PausedEvery))
|
||||
fails = true
|
||||
w.step(at.Add(10*time.Second + 3*PausedEvery))
|
||||
if len(said) != 3 || said[0] || !said[1] || !said[2] {
|
||||
t.Fatalf("said %v", said)
|
||||
}
|
||||
}
|
||||
@@ -92,3 +92,14 @@
|
||||
foreground = "#ffffff"
|
||||
frame_color = "#ff0000"
|
||||
timeout = 0
|
||||
|
||||
# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's
|
||||
# code, category mesh.secret) and any transient one leave no entry in the history, which
|
||||
# node-notifier.history serves to every caller of the mesh's verbs.
|
||||
[mesh-secret]
|
||||
category = "mesh.secret"
|
||||
history_ignore = yes
|
||||
|
||||
[transient-history-ignore]
|
||||
match_transient = yes
|
||||
history_ignore = yes
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"path": "${machine:account-home}/.config/dunst/dunstrc",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"<b>%s</b>\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n"
|
||||
"content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"<b>%s</b>\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n"
|
||||
},
|
||||
{
|
||||
"id": "notifier",
|
||||
@@ -96,6 +96,7 @@
|
||||
},
|
||||
"emits": [
|
||||
"action-chosen",
|
||||
"started"
|
||||
"started",
|
||||
"paused"
|
||||
]
|
||||
}
|
||||
|
||||
+83
-46
@@ -1,20 +1,71 @@
|
||||
# messenger
|
||||
|
||||
The holder of the `operator-channel` seat: how the mesh tells its operator what it noticed (novox/hq
|
||||
to-be 45 §5, ADR 0227, research 028 — the minimal form, Q1a, Q5a, Q8).
|
||||
The holder of the `operator-channel` seat: the router (novox/hq to-be 45 §5, to-be 46, ADR 0227, ADR 0234,
|
||||
ADR 0259). It decides what is said to the operator and on which channel, holds every ask a module makes of
|
||||
the operator, judges each answer and issues the **warrant** its asker acts on. It performs nothing, holds no
|
||||
channel's client and no channel's secret.
|
||||
|
||||
- **Declares and holds `operator-channel`**, held once for the mesh, serving `open`, `history` and
|
||||
`notify`.
|
||||
- **Reads the controller's open conditions** (`seat:mesh-controller.conditions`) — the state it
|
||||
believes, before and beside the events.
|
||||
- **Consumes the controller's condition events** — `mesh-controller.condition-raised`, `-changed`,
|
||||
`-cleared` — and decides what is sent. The controller calls nobody. The events are read in one
|
||||
place, `cmd/messenger/condition.go`, which states every assumption it makes about their shape.
|
||||
- **Two channels:** Telegram (a bot to the operator's chat) and the desktop notifier — the
|
||||
`node-notifier` seat's `send` verb on the machine the operator sits at (ADR 0208), asked through the
|
||||
mesh. Nothing new runs on that machine.
|
||||
- **Keeps its open messages in its own state** (`open`; the recent sends in `sent`), so a restart
|
||||
forgets nothing (ADR 0201).
|
||||
- **Declares three seats:** `operator-channel` (held here: `open`, `history`, `notify`, `asks`; an ask and
|
||||
its cancel accepted under the asker's own name, the warrant emitted to that asker alone; the asks kept as
|
||||
a record each asker reads under its own name), and the two kinded benches its channels hold, `channel`
|
||||
(`show`, `edit`, `send`, each under the holder's kind) and `intake` (`choice`, `link`, `failed`, `standing`,
|
||||
and the proof `code`, request and reply only). Only `channel` and `intake` may be kinded.
|
||||
- **Reads the controller's open conditions** (`seat:mesh-controller.conditions`) and **consumes its
|
||||
condition events**; the events are read in one place, `cmd/messenger/condition.go`.
|
||||
- **The channels are modules of their own**: `telegram` (kind `telegram`, reaches the operator away, proves
|
||||
its sender) and `desk-channel` (kind `desktop`, the machines' notifiers, proves nothing). Which kinds exist
|
||||
and what each promises is the controller's word, in this module's membership; whether each can send now
|
||||
is the channel's own, said on `intake.standing`.
|
||||
- **Keeps its state on the bus** (ADR 0201): `open` and `sent` (the messages), `asks` (every ask, closed ones
|
||||
for 30 days), `identities` (the operator's linked accounts, one per kind, and a link asked for).
|
||||
|
||||
## Asks, answers and warrants (novox/hq ADR 0259)
|
||||
|
||||
- **An ask** arrives on `mesh.seat.operator-channel.accept.ask.<asker>`; the asker is the subject's last
|
||||
token, which the bus grants that module alone. It is checked (the asks contract, the content rule, at
|
||||
most three open per asker) and recorded before its work is taken; a refusal is said to the asker on its
|
||||
`decided` subject, in words.
|
||||
- **It is offered on every kind that can carry one of its answers**: an acknowledgement wherever a choice
|
||||
can be made; an approval only where the kind proves its sender and an account is linked; nothing that
|
||||
destroys until an authenticator-app code exists. Each option has a one-time ticket per kind; the words
|
||||
say where an answer not offered here can be given ("Release or Stop: answer on Telegram, or from the mesh
|
||||
MCP server."). An ask about a condition is shown on that condition's message.
|
||||
- **An answer is judged in order**, refused at the first failure and said where it was given: the ticket is
|
||||
known, unused and for this kind; the ask is open and unexpired; the sender is the linked account where
|
||||
the kind proves its sender; the kind's capabilities meet the level; an approval comes from an account
|
||||
linked an hour or more. The ask is then closed by compare-and-set — a second answer loses — and the
|
||||
warrant published to the asker alone. A warrant the bus did not take is published again every minute.
|
||||
- **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would.
|
||||
- **Linking** (`/start` to the bot): a six-digit code is shown, with the account's name and number, on a
|
||||
channel whose words stay private (the desk) for ten minutes; the operator sends it to the bot, and the
|
||||
account is linked. It acknowledges at once and approves after an hour. **The code is shown now or never**:
|
||||
while the desk cannot show it (its notifications paused, nobody there), no link is begun and no code waits
|
||||
in a queue. At most three links an hour per account, and ten per kind, counted in the router's state. Every
|
||||
link and unlink is said on every channel. An account unlinked less than a day ago is not linked again by a
|
||||
code; one account per kind is checked again as a link is made.
|
||||
- **Unlinking is asked of the account that is linked** (`messenger_unlink` asks it): an approve question put
|
||||
to that account alone. Its approval unlinks it, told to it first, by name, then everywhere. **A link within
|
||||
a day of an unlink waits for the replaced account's approval**, asked of it by name. Each unlink or relink
|
||||
question binds the account it is about, so its approval never acts on another account linked meanwhile.
|
||||
**An account still in its first hour**, which cannot approve yet, is unlinked from the desk: when another
|
||||
account sends `/start`, the desk is asked whether to unlink it.
|
||||
- **When that account is lost with its phone**, root at the router's machine leaves a file in this module's
|
||||
state directory — `unlink.<kind>`, or `allow-link.<kind>` for a link that waits, a regular file owned by
|
||||
uid 0 — and the router does it within the minute, said on every channel with what was done, and every open
|
||||
question of the router's own ends. A file not made by root is refused and said.
|
||||
- **A channel proving its sender says only to the linked account**: none linked, it is not ready, and asks
|
||||
for approval are not offered on it. **An approval rests on the controller's positive word**, asked of its
|
||||
verb `root-free` for the router's machine and the channel's, live when an answer arrives (a word at most
|
||||
30 s old renders the buttons): only a machine it says is free carries an approval. A router that does not
|
||||
know its machine, a controller that does not answer, a machine its answer leaves out, or a word on the
|
||||
channel kinds older than two minutes all fail closed.
|
||||
- **An ask that offers an approval is answered only where it could be approved**, whichever option is
|
||||
chosen: the desk, which proves nothing of who clicked, is not offered it, and an acknowledgement from a
|
||||
fresh link does not end it. An asker wanting an acknowledgement at the desk asks it alone.
|
||||
- **Each ask's message says who asks** (as the bus names it), what each answer does, and what happens if
|
||||
nobody answers. A warrant is published under a message id nobody can foresee.
|
||||
- **The router runs as its own account `messenger`, on a bus account of its own**, never carried by the
|
||||
machine's runtime (ADR 0259 §8): its state and its bus credential are that account's.
|
||||
|
||||
## What is sent, and when
|
||||
|
||||
@@ -49,18 +100,10 @@ operator can do themselves. **A warning that needs nothing is not sent**: it sta
|
||||
controller's conditions, the history says it was kept quiet, and it is said the moment it needs the
|
||||
operator (urgent at its bound, or its words now say "Needs you:"). An urgent condition is always said.
|
||||
|
||||
**A notification offers answers** (`cmd/messenger/answers.go`), limited by novox/hq ADR 0258: **a desk
|
||||
click performs only an acknowledgement**. Any process of the operator's account can choose a dunst action
|
||||
(`dunstctl action`), so a click proves nothing about who chose. Until answers are authorised (to-be 46
|
||||
phase 5) the only answers offered and performed are **Details** and **Silence for a week** (the allow-list
|
||||
`answerable`); release, stop, start and restart are said in words ("Needs you: release it, or stop it.")
|
||||
and offered as no answer. Each answer is a token made for one message of one condition: bound to the
|
||||
machines it was shown on, good for a day, used once, refused for a condition cleared or silenced, and one
|
||||
event is acted on once. A refused answer is said on the desk where it was chosen, never dropped. When the
|
||||
dunst bundle says it started (`dunst.started`), the open notifications that offer answers are shown there
|
||||
again with fresh tokens. A silence is recorded with the cause `operator-answer`, which the hand-act log
|
||||
does not count as a repair. A warning kept quiet is also said once after a day. Telegram shows the words
|
||||
but no buttons.
|
||||
**A message offers the answers of the ask about it, and Details** (`cmd/messenger/answers.go`). The router
|
||||
calls no verb of a condition's: the controller asks for the answers a condition names, and performs the one
|
||||
chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen. A
|
||||
warning kept quiet is also said once after a day.
|
||||
|
||||
| When | What |
|
||||
|---|---|
|
||||
@@ -71,9 +114,8 @@ but no buttons.
|
||||
| cleared and raised again within 10 min | the same message, edited back to open — not a new one |
|
||||
| silenced | nothing, its clearing included |
|
||||
|
||||
- **Routing:** urgent to Telegram and the desktop; warning to the desktop when a session there
|
||||
answers, otherwise to Telegram. The notifier answering is how "the operator's session is there" is
|
||||
read until presence is decided (research 028 Q4).
|
||||
- **Routing:** urgent to every kind that can send; a warning to the desk where it can send, otherwise to
|
||||
the channel that reaches the operator away; an ask to every kind that carries one of its answers.
|
||||
- **Bursts are one message.** What is to be said on a channel is held **30 s** from the first and goes
|
||||
out together: alone, as itself; several, as one digest (`WARNING: 5 new warnings`, a line each). An
|
||||
urgent message goes at once when nothing went out on its channel in the last 30 s.
|
||||
@@ -91,21 +133,13 @@ but no buttons.
|
||||
when the open conditions were last read, why they could not be, and how many events were taken as
|
||||
history. An event that cannot be read
|
||||
is refused by name, counted, and told to the operator once an hour.
|
||||
- **No answering back.** Acknowledging is `conditions silence`, through the mesh.
|
||||
|
||||
## What the operator gives
|
||||
|
||||
Nothing is sent until these are given; `messenger_status` says which is missing.
|
||||
|
||||
1. **The bot token**, as this module's own secret: `secret accept <machine> messenger telegram-token`,
|
||||
then push that machine. A value the mesh minted because none was accepted is recognised as not a
|
||||
bot token and said so.
|
||||
2. **The chat id**, as a setting: `settings` for `messenger` with `{"telegram-chat-id": "<id>"}`.
|
||||
3. **The desktop machines**, as a setting: `{"desktop-machines": ["<the machine the operator sits at>", …]}`.
|
||||
Without it, warnings go to Telegram.
|
||||
4. **The time zone**, as a setting: `{"time-zone": "<an IANA zone name>"}`. Every time a message says is
|
||||
said in it; not given, the zone of the machine this runs on. A name this machine does not know is
|
||||
logged and that zone used instead.
|
||||
1. **The time zone**, as a setting: `{"time-zone": "<an IANA zone name>"}`. Every time a message says is
|
||||
said in it; not given, the zone of the machine this runs on.
|
||||
2. **The channels**: assign `telegram` and `desk-channel` (each says in its README what it needs). Linking
|
||||
an account as the operator is done on the channel, never by a setting.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -114,15 +148,18 @@ Nothing is sent until these are given; `messenger_status` says which is missing.
|
||||
| `operator-channel.open` | what is open now, urgent first, with where it went, silenced, reminded, held, refused, unsent |
|
||||
| `operator-channel.history` | what was said lately, and the refusals |
|
||||
| `operator-channel.notify` | a message from a module using the seat: key, severity, summary, and best a plain `headline`, `explanation` and `resolved` line; `clear` to end it |
|
||||
| `messenger_status` | whether the operator can be reached and why not; `check` asks Telegram whether the token works |
|
||||
| `messenger_recent` | the recent sends, edits, digests and failures |
|
||||
| `messenger_test` | a test message now, to telegram, desktop or both |
|
||||
| `operator-channel.asks` | the asks held, open first: asker, state, where offered, the warrant, whether the asker was told |
|
||||
| `messenger_status` | whether the operator can be reached and why not, per channel kind |
|
||||
| `messenger_recent` | the recent sends, edits, digests, answers and failures |
|
||||
| `messenger_test` | a test message now, to one kind or every one |
|
||||
| `messenger_check` | whether some words may leave the mesh |
|
||||
| `messenger_identities` | the accounts linked as the operator, and from when each approves |
|
||||
| `messenger_unlink` | unlink a kind's account, said on every channel |
|
||||
|
||||
## Where it runs
|
||||
|
||||
Held once for the mesh: assign it to one machine whose tool runtime runs as root, since its secret
|
||||
and settings are root's files at 0600 (as every runtime-carried module's are).
|
||||
Held once for the mesh, on the control node: its settings are root's files at 0600 (as every
|
||||
runtime-carried module's are). It holds no secret.
|
||||
|
||||
## Not yet
|
||||
|
||||
|
||||
@@ -1,41 +1,31 @@
|
||||
package main
|
||||
|
||||
// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253, as
|
||||
// limited by ADR 0258).
|
||||
// What the operator needs to do, and answering from a message (novox/hq ADR 0253, ADR 0258, ADR 0259).
|
||||
//
|
||||
// - **Every explanation opens with a verdict**, written by the controller: "Nothing for you to do." or
|
||||
// "Needs you:" and one thing the operator can do themselves.
|
||||
// - **A warning that needs nothing is not sent.** It is kept open here and in the controller's
|
||||
// conditions, the history says it was kept quiet, and it is said when it needs the operator — urgent
|
||||
// at its bound, or its words changed to "Needs you:" (an event, or a reading of the state) — and once
|
||||
// after a day in any case (QuietRemindAfter), so nothing stays silent for ever.
|
||||
// - **A desk click performs only an acknowledgement** (ADR 0234 §8). Any process of the operator's account
|
||||
// can choose a notification's action (`dunstctl action`), so a click proves nothing about who chose;
|
||||
// until answers are authorised (to-be 46 phase 5) the only answers offered and performed are Details
|
||||
// and silencing a condition. Everything else — release, stop, start, restart — is said in words and
|
||||
// offered as no answer. The allow-list is `answerable`; an action outside it is never offered, and a
|
||||
// token for one is refused.
|
||||
// - **A token is an answer's one-time ticket**: made for one message of one condition, bound to the
|
||||
// machines it was shown on, valid for TokenFor, used once. An answer for a condition cleared or
|
||||
// silenced, from another machine, used, expired or unknown, is refused — and the refusal is shown where
|
||||
// the answer came from, so a click is never silently dead. One event is acted on once, whatever its
|
||||
// redelivery.
|
||||
// - **A notifier that restarted lost the answers it was waiting for**; when it says it started, the open
|
||||
// notifications that offer answers are shown there again with fresh tokens (Reoffer).
|
||||
// at its bound, its words changed to "Needs you:", or an ask about it arrived — and once after a day in
|
||||
// any case (QuietRemindAfter), so nothing stays silent for ever.
|
||||
// - **The router performs nothing** (ADR 0259). A message offers the answers of the ask about it, which
|
||||
// its asker performs on a warrant (asks.go), and Details, which performs nothing and is shown where it
|
||||
// was chosen. The condition's own actions are never called from here: the controller asks for them.
|
||||
// - **A ticket is an answer's one-time ticket**: made for one message on one channel kind, good for
|
||||
// TokenFor, used once; refused for a record cleared or silenced, used, expired, from another kind or
|
||||
// unknown — and the refusal is shown where the answer came from. One answer is acted on once,
|
||||
// whatever its redelivery.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Action is one answer a condition offers: a label, and the seat verb called with these arguments when
|
||||
// it is chosen. Machine is set for a seat every machine holds. An argument "why" given empty is filled
|
||||
// with the operator's words.
|
||||
// Action is one answer a condition names: a label, and the seat verb its producer calls when it is chosen.
|
||||
// Kept as the controller says it; the router offers a condition's answers only through an ask about it.
|
||||
type Action struct {
|
||||
Label string `json:"label"`
|
||||
Verb string `json:"verb"`
|
||||
@@ -43,45 +33,25 @@ type Action struct {
|
||||
Arguments map[string]string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// Grant is one token's answer: the action (or -1, Details), when it was made, and whether it was used.
|
||||
// Grant is one Details ticket: the kind it was shown on, when it was made, and whether it was used.
|
||||
type Grant struct {
|
||||
Index int `json:"index"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Issued time.Time `json:"issued"`
|
||||
Used bool `json:"used,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
// DetailsLabel is the answer every condition's notification offers.
|
||||
// DetailsLabel is the answer every condition's message offers.
|
||||
DetailsLabel = "Details"
|
||||
// MostActions bounds what a notification offers, Details included.
|
||||
// MostActions bounds what a message offers, Details included.
|
||||
MostActions = 4
|
||||
// TokenFor is how long an answer's token is good for.
|
||||
// TokenFor is how long a Details ticket is good for.
|
||||
TokenFor = 24 * time.Hour
|
||||
// KeptEvents is how many answered events are remembered, so a redelivered one is acted on once.
|
||||
// KeptEvents is how many answers are remembered, so a redelivered one is acted on once.
|
||||
KeptEvents = 200
|
||||
)
|
||||
|
||||
// answerable is the allow-list of answers a desk click performs (ADR 0258): silencing a condition, an
|
||||
// acknowledgement any granted principal may already make. Nothing else, until answers are authorised.
|
||||
func answerable(a Action) bool {
|
||||
if a.Verb != "mesh-controller.conditions" || a.Machine != "" || a.Arguments["silence"] == "" {
|
||||
return false
|
||||
}
|
||||
for k := range a.Arguments {
|
||||
switch k {
|
||||
case "silence", "for", "why", "cause":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// answerableFor is answerable for this record: a silence of its own condition, and of no other.
|
||||
func answerableFor(r *Record, a Action) bool {
|
||||
return answerable(a) && a.Arguments["silence"] == r.Key
|
||||
}
|
||||
|
||||
func sameActions(a, b []Action) bool {
|
||||
x, _ := json.Marshal(a)
|
||||
y, _ := json.Marshal(b)
|
||||
@@ -89,82 +59,83 @@ func sameActions(a, b []Action) bool {
|
||||
}
|
||||
|
||||
// needsNothing is a record the operator need not hear of now: words of its own that open "Nothing for
|
||||
// you to do.", and only a warning. A record without words (an older controller, a notice) is said.
|
||||
// you to do.", only a warning, and nothing asked about it. A record without words is said.
|
||||
func needsNothing(r *Record) bool {
|
||||
return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition"
|
||||
return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition" && r.Ask == ""
|
||||
}
|
||||
|
||||
// choices are the answers a record's next message offers, each with a fresh token; the tokens of its
|
||||
// earlier messages end with them.
|
||||
func (h *Holder) choices(r *Record) []Choice {
|
||||
if r.Origin != "condition" {
|
||||
return nil
|
||||
// choices are the answers a record's next message offers on one kind: the open ask's options offered
|
||||
// there, and Details for a condition, with a fresh ticket replacing that kind's earlier one.
|
||||
func (h *Holder) choices(r *Record, kind string) []Choice {
|
||||
var out []Choice
|
||||
if rec := h.openAsk(r); rec != nil {
|
||||
out = h.offers(rec, kind)
|
||||
}
|
||||
if r.Origin != "condition" || len(out) >= MostActions {
|
||||
return out
|
||||
}
|
||||
now := h.Now()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
r.Tokens = map[string]Grant{}
|
||||
var out []Choice
|
||||
for i, a := range r.Actions {
|
||||
if !answerableFor(r, a) || len(out) == MostActions-1 {
|
||||
continue
|
||||
}
|
||||
t := token()
|
||||
r.Tokens[t] = Grant{Index: i, Issued: now}
|
||||
out = append(out, Choice{Token: t, Label: a.Label})
|
||||
if r.Tokens == nil {
|
||||
r.Tokens = map[string]Grant{}
|
||||
}
|
||||
t := token()
|
||||
r.Tokens[t] = Grant{Index: -1, Issued: now}
|
||||
for t, g := range r.Tokens {
|
||||
if g.Kind == kind {
|
||||
delete(r.Tokens, t)
|
||||
}
|
||||
}
|
||||
t := ticket()
|
||||
r.Tokens[t] = Grant{Index: -1, Kind: kind, Issued: now}
|
||||
return append(out, Choice{Token: t, Label: DetailsLabel})
|
||||
}
|
||||
|
||||
func token() string {
|
||||
var b [12]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// shownOn are the machines a record's desktop message was shown on.
|
||||
func shownOn(r *Record) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(r.Sent["desktop"], ",") {
|
||||
if m, _, ok := strings.Cut(part, "="); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Chosen takes an answer a channel handed back: the token of a choice, on a machine, in an event. It
|
||||
// performs an allowed answer, shows what came of it — or why it was refused — where it was chosen, and
|
||||
// answers that in words. The same event is acted on once.
|
||||
func (h *Holder) Chosen(channel, machine, tok, event string) (string, error) {
|
||||
// Chosen takes an answer a channel of a kind said it was given: an ask's option is judged and, when it
|
||||
// stands, closes the ask with a warrant for its asker (asks.go); Details are shown where they were asked.
|
||||
// Anything else is refused, and said there. The same answer is acted on once.
|
||||
func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) {
|
||||
// The controller's root-free word is asked before the work is taken (the confirmation review of 2026-10-09):
|
||||
// up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer,
|
||||
// and judged with in it.
|
||||
root := h.rootWordFor(kind)
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
if event != "" {
|
||||
if outcome, done := h.answered[event]; done {
|
||||
if c.ID != "" {
|
||||
if outcome, done := h.answered[c.ID]; done {
|
||||
h.mu.Unlock()
|
||||
return outcome, nil
|
||||
}
|
||||
}
|
||||
key := ""
|
||||
if h.tickets != nil {
|
||||
key = h.tickets[c.Ticket]
|
||||
}
|
||||
now := h.Now()
|
||||
var r *Record
|
||||
var g Grant
|
||||
for _, rec := range h.open {
|
||||
if gr, ok := rec.Tokens[tok]; ok {
|
||||
if gr, ok := rec.Tokens[c.Ticket]; ok {
|
||||
r, g = rec, gr
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !c.At.IsZero() && now.Sub(c.At) > FreshFor {
|
||||
// Replayed from the stream long after it was given: history, never an answer.
|
||||
h.Logf("[messenger] an answer on %s from %s is %s old and was not acted on", kind, c.At.Format(time.RFC3339), roughly(now.Sub(c.At)))
|
||||
h.remember(c.ID, "too old")
|
||||
return "too old", errorf("it was given %s ago", roughly(now.Sub(c.At)))
|
||||
}
|
||||
if key != "" {
|
||||
out, err := h.judge(kind, c, key, root)
|
||||
h.remember(c.ID, out)
|
||||
return out, err
|
||||
}
|
||||
refuse := func(why string) (string, error) {
|
||||
h.Logf("[messenger] an answer on %s from %s was refused: %s", channel, machine, why)
|
||||
h.remember(event, "refused: "+why)
|
||||
h.record(Sent{At: now, Channel: channel, Key: keyOf(r), What: "answer", Outcome: "refused: " + why})
|
||||
_, _ = h.showOn(channel, machine, Message{Title: "That answer was not taken",
|
||||
Body: capital(why) + ". Nothing was done.", Quiet: true})
|
||||
return "refused", errorf("%s", why)
|
||||
out, err := h.refuseChoice(kind, c, why)
|
||||
h.remember(c.ID, out)
|
||||
return out, err
|
||||
}
|
||||
switch {
|
||||
case r == nil:
|
||||
@@ -177,119 +148,28 @@ func (h *Holder) Chosen(channel, machine, tok, event string) (string, error) {
|
||||
return refuse("it was already chosen once")
|
||||
case now.Sub(g.Issued) > TokenFor:
|
||||
return refuse("it is more than a day old")
|
||||
case channel == "desktop" && !contains(shownOn(r), machine):
|
||||
return refuse("it was not shown on " + machine)
|
||||
case g.Index >= len(r.Actions) || (g.Index >= 0 && !answerableFor(r, r.Actions[g.Index])):
|
||||
return refuse("it is not an answer a notification may give")
|
||||
case g.Kind != "" && g.Kind != kind:
|
||||
return refuse("it was not offered here")
|
||||
}
|
||||
h.mu.Lock()
|
||||
g.Used = true
|
||||
r.Tokens[tok] = g
|
||||
r.Tokens[c.Ticket] = g
|
||||
h.mu.Unlock()
|
||||
headline, _ := wordsOf(r)
|
||||
if g.Index == -1 {
|
||||
// Each line held to the content rule on its own: a line that carries what may not leave is said
|
||||
// withheld, and the rest is still shown.
|
||||
var lines []string
|
||||
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
|
||||
if refusal, ok := Check(l); !ok {
|
||||
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
|
||||
}
|
||||
lines = append(lines, l)
|
||||
// Each line held to the content rule on its own: a line that carries what may not leave is said
|
||||
// withheld, and the rest is still shown.
|
||||
var lines []string
|
||||
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
|
||||
if refusal, ok := Check(l); !ok {
|
||||
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
|
||||
}
|
||||
_, err := h.showOn(channel, machine, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")})
|
||||
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
|
||||
h.remember(event, "shown")
|
||||
h.persist(r)
|
||||
return "shown", err
|
||||
lines = append(lines, l)
|
||||
}
|
||||
a := r.Actions[g.Index]
|
||||
args := map[string]any{}
|
||||
for k, v := range a.Arguments {
|
||||
args[k] = v
|
||||
}
|
||||
if v, ok := a.Arguments["why"]; ok && v == "" {
|
||||
args["why"] = fmt.Sprintf("chosen on the %s notification %q on %s (a desk click: whoever was at the "+
|
||||
"operator's session)", channel, headline, machine)
|
||||
}
|
||||
var err error
|
||||
if h.Ask == nil {
|
||||
err = errorf("this holder cannot call the mesh")
|
||||
} else {
|
||||
var raw json.RawMessage
|
||||
raw, err = h.Ask("seat:"+a.Verb, args)
|
||||
if err == nil {
|
||||
err = refusalIn(raw)
|
||||
}
|
||||
}
|
||||
outcome := "done"
|
||||
title := a.Label + ": done. " + headline
|
||||
body := "Chosen at " + now.In(h.zone()).Format("15:04") + "."
|
||||
if err != nil {
|
||||
outcome = "failed: " + err.Error()
|
||||
title = "Could not " + strings.ToLower(a.Label) + ": " + headline
|
||||
body = "The mesh refused it: " + firstLineOf(err.Error())
|
||||
if _, ok := Check(body); !ok {
|
||||
body = "The mesh refused it; the reason carries what may not be shown here."
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
r.Answered = a.Label + ": " + outcome
|
||||
sent := r.Sent[channel]
|
||||
h.mu.Unlock()
|
||||
err := h.showOn(kind, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n"), Reply: c.ID})
|
||||
h.record(Sent{At: now, Channel: kind, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
|
||||
h.remember(c.ID, "shown")
|
||||
h.persist(r)
|
||||
m := Message{Title: title, Body: body, Quiet: err == nil}
|
||||
if ch := h.channelNamed(channel); ch != nil && sent != "" && ch.CanEdit() {
|
||||
if e := ch.Edit(sent, m); e != nil {
|
||||
_, _ = h.showOn(channel, machine, m)
|
||||
}
|
||||
} else {
|
||||
_, _ = h.showOn(channel, machine, m)
|
||||
}
|
||||
h.remember(event, outcome)
|
||||
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "answer " + a.Label, Outcome: outcome})
|
||||
h.Logf("[messenger] %s was chosen for %s on %s (%s): %s", a.Label, r.Key, channel, machine, outcome)
|
||||
return outcome, err
|
||||
}
|
||||
|
||||
// Reoffer shows again, on a machine whose notifier restarted and so lost the answers it waited for, every
|
||||
// open message shown there that offers answers — with fresh tokens, in place of the old one.
|
||||
func (h *Holder) Reoffer(machine string) int {
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
var recs []*Record
|
||||
for _, r := range h.open {
|
||||
if r.Cleared.IsZero() && len(r.Tokens) > 0 && contains(shownOn(r), machine) {
|
||||
recs = append(recs, r)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
sort.Slice(recs, func(i, j int) bool { return recs[i].Key < recs[j].Key })
|
||||
n := 0
|
||||
for _, r := range recs {
|
||||
if r.silenced(h.Now()) || h.Desktop == nil {
|
||||
continue
|
||||
}
|
||||
m := h.say(r, "raised")
|
||||
id := ""
|
||||
for _, part := range strings.Split(r.Sent["desktop"], ",") {
|
||||
if strings.HasPrefix(part, machine+"=") {
|
||||
id = part
|
||||
}
|
||||
}
|
||||
if err := h.Desktop.Edit(id, m); err != nil {
|
||||
h.Logf("[messenger] could not offer %s again on %s: %v", r.Key, machine, err)
|
||||
continue
|
||||
}
|
||||
h.persist(r)
|
||||
n++
|
||||
}
|
||||
if n > 0 {
|
||||
h.Logf("[messenger] the notifier on %s started again: %d message(s) with answers shown there again", machine, n)
|
||||
}
|
||||
return n
|
||||
return "shown", err
|
||||
}
|
||||
|
||||
func (h *Holder) remember(event, outcome string) {
|
||||
@@ -310,13 +190,6 @@ func (h *Holder) remember(event, outcome string) {
|
||||
h.answered[event] = outcome
|
||||
}
|
||||
|
||||
func keyOf(r *Record) string {
|
||||
if r == nil {
|
||||
return "(none)"
|
||||
}
|
||||
return r.Key
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
@@ -341,43 +214,49 @@ func firstLineOf(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// refusalIn reads a tool's answer for a refusal: an error result is an error.
|
||||
func refusalIn(raw json.RawMessage) error {
|
||||
var wrapped struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrapped) == nil {
|
||||
if wrapped.IsError && len(wrapped.Content) > 0 {
|
||||
return errorf("%s", wrapped.Content[0].Text)
|
||||
}
|
||||
if wrapped.Error != "" {
|
||||
return errorf("%s", wrapped.Error)
|
||||
// Failed takes a channel's word that a message did not arrive: the channel is said to be failing, and the
|
||||
// record it carried is tried again — unless the failure is permanent, which is said and not repeated.
|
||||
func (h *Holder) Failed(kind string, f asks.Failed) {
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.noteChannel(kind, errorf("%s", f.Why))
|
||||
h.mu.Lock()
|
||||
var r *Record
|
||||
for _, rec := range h.open {
|
||||
if rec.Sent[kind] == f.Handle && f.Handle != "" {
|
||||
r = rec
|
||||
}
|
||||
}
|
||||
return nil
|
||||
if r != nil && r.Cleared.IsZero() && !f.Permanent {
|
||||
delete(r.Sent, kind)
|
||||
if r.Pending == "" {
|
||||
r.Pending = "raised"
|
||||
}
|
||||
}
|
||||
desk := h.Bench != nil && h.Bench.roleOf(kind) == RoleDesk
|
||||
h.mu.Unlock()
|
||||
// A warning the desk could not show goes to the channel that reaches the operator away, as it did when the
|
||||
// desk was the router's own (the correctness review of 2026-10-08).
|
||||
if desk && r != nil && r.Cleared.IsZero() && r.Severity != Urgent && h.Away != nil && h.Away.Ready() == nil {
|
||||
h.enqueue(h.Away.Name(), item{Key: r.Key, What: "raised", Severity: r.Severity, At: h.Now()})
|
||||
h.mu.Lock()
|
||||
r.Pending = ""
|
||||
h.mu.Unlock()
|
||||
h.flush(h.Now(), false)
|
||||
}
|
||||
outcome := "failed: " + f.Why
|
||||
if f.Permanent {
|
||||
outcome = "failed for good: " + f.Why
|
||||
}
|
||||
h.record(Sent{At: h.Now(), Channel: kind, Key: keyOrNone(r), What: "delivery", Outcome: outcome})
|
||||
if r != nil {
|
||||
h.persist(r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Holder) channelNamed(name string) Channel {
|
||||
for _, ch := range h.channels() {
|
||||
if ch.Name() == name {
|
||||
return ch
|
||||
}
|
||||
func keyOrNone(r *Record) string {
|
||||
if r == nil {
|
||||
return "(none)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// showOn shows a message of the holder's own on one channel, on one machine where the channel has several.
|
||||
func (h *Holder) showOn(channel, machine string, m Message) (string, error) {
|
||||
ch := h.channelNamed(channel)
|
||||
if ch == nil {
|
||||
return "", errorf("no channel %s", channel)
|
||||
}
|
||||
if d, ok := ch.(*Desktop); ok && machine != "" {
|
||||
return d.showOne(machine, m)
|
||||
}
|
||||
return ch.Send(m)
|
||||
return r.Key
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,152 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const goodToken = "123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsawQ"
|
||||
|
||||
func tokenFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "telegram-token")
|
||||
if content != "" {
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestTelegramSaysWhatItLacks(t *testing.T) {
|
||||
for _, c := range []struct{ token, chat, says string }{
|
||||
{"", "42", "not on this machine yet"},
|
||||
{"r4nd0mlyMadeByTheMeshBecauseNobodyAcceptedOne", "42", "not a bot token"},
|
||||
{goodToken, "", "telegram-chat-id is not given"},
|
||||
{goodToken, "not a chat", "is not a chat id"},
|
||||
} {
|
||||
tg := NewTelegram(TelegramConfig{TokenFile: tokenFile(t, c.token), ChatID: func() string { return c.chat }})
|
||||
err := tg.Ready()
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%+v: %v", c, err)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), goodToken) {
|
||||
t.Errorf("the token is in the words")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramSendsEditsAndNeverSaysItsToken(t *testing.T) {
|
||||
var asked []string
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
asked = append(asked, r.URL.Path)
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var b map[string]any
|
||||
_ = json.Unmarshal(raw, &b)
|
||||
bodies = append(bodies, b)
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/sendMessage"):
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":77}}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/editMessageText"):
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: message to edit not found"}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"username":"mesh_bot"}}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := TelegramAPI
|
||||
TelegramAPI = srv.URL
|
||||
defer func() { TelegramAPI = old }()
|
||||
tg := NewTelegram(TelegramConfig{TokenFile: tokenFile(t, goodToken+"\n"), ChatID: func() string { return "-1001" }})
|
||||
id, err := tg.Send(Message{Title: "URGENT: x", Body: "key: a.b.c"})
|
||||
if err != nil || id != "77" {
|
||||
t.Fatalf("%q %v", id, err)
|
||||
}
|
||||
if asked[0] != "/bot"+goodToken+"/sendMessage" || bodies[0]["chat_id"] != "-1001" || bodies[0]["text"] != "URGENT: x\nkey: a.b.c" {
|
||||
t.Fatalf("%v %v", asked, bodies[0])
|
||||
}
|
||||
err = tg.Edit("77", Message{Title: "CLEARED"})
|
||||
if err == nil || !strings.Contains(err.Error(), "message to edit not found") || strings.Contains(err.Error(), goodToken) {
|
||||
t.Fatalf("edit: %v", err)
|
||||
}
|
||||
if bodies[1]["message_id"] != float64(77) {
|
||||
t.Fatalf("message id: %v", bodies[1]["message_id"])
|
||||
}
|
||||
if who, err := tg.Who(); err != nil || who != "mesh_bot" {
|
||||
t.Fatalf("%q %v", who, err)
|
||||
}
|
||||
// Nothing answers: the error is in words, without the URL that carries the token.
|
||||
srv.Close()
|
||||
_, err = tg.Send(Message{Title: "x"})
|
||||
if err == nil || strings.Contains(err.Error(), goodToken) || strings.Contains(err.Error(), "/bot") {
|
||||
t.Fatalf("unreachable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDesktopAsksTheNotifierSeatOnEachMachine(t *testing.T) {
|
||||
var asked []string
|
||||
d := &Desktop{
|
||||
Machines: func() []string { return []string{"one", "two"} },
|
||||
Ask: func(key string, body any) (json.RawMessage, error) {
|
||||
asked = append(asked, key)
|
||||
args := body.(map[string]any)
|
||||
if args["body"] != "a <b>" || args["urgency"] != "critical" {
|
||||
t.Errorf("args: %v", args)
|
||||
}
|
||||
if strings.HasSuffix(key, "@two") {
|
||||
return nil, errors.New("the account is not logged in")
|
||||
}
|
||||
return json.RawMessage(`{"id":12}`), nil
|
||||
},
|
||||
}
|
||||
id, err := d.Send(Message{Title: "t", Body: "a <b>", Urgent: true})
|
||||
if err != nil || id != "one=12" {
|
||||
t.Fatalf("%q %v", id, err)
|
||||
}
|
||||
if asked[0] != "seat:node-notifier.send@one" || asked[1] != "seat:node-notifier.send@two" {
|
||||
t.Fatalf("%v", asked)
|
||||
}
|
||||
if d.LastAnswers()["two"] == "" || d.LastAnswers()["one"] != "" {
|
||||
t.Fatalf("%v", d.LastAnswers())
|
||||
}
|
||||
asked = nil
|
||||
if err := d.Edit("one=12", Message{Title: "t", Body: "a <b>", Urgent: true}); err != nil || len(asked) != 1 {
|
||||
t.Fatalf("edit asked %v: %v", asked, err)
|
||||
}
|
||||
none := &Desktop{Machines: func() []string { return nil }}
|
||||
if err := none.Ready(); err == nil || !strings.Contains(err.Error(), "desktop-machines") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNotifiersAnswerIsReadInEitherShape(t *testing.T) {
|
||||
for raw, want := range map[string]int{
|
||||
`{"id":5}`: 5,
|
||||
`{"content":[{"type":"text","text":"{\"id\":6}"}]}`: 6,
|
||||
`"{\"id\":7}"`: 7,
|
||||
} {
|
||||
if got, err := notificationID(json.RawMessage(raw)); err != nil || got != want {
|
||||
t.Errorf("%s: %d %v", raw, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := notificationID(json.RawMessage(`{"content":[{"text":"no session"}],"isError":true}`)); err == nil {
|
||||
t.Errorf("an error answer read as an id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsAreReadAsTheMeshMergesThem(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
_ = os.WriteFile(p, []byte(`{"telegram-chat-id": 123456, "desktop-machines": ["a", " ", "b"]}`), 0o600)
|
||||
s, err := readSettings(p)
|
||||
if err != nil || s.TelegramChatID != "123456" || len(s.DesktopMachines) != 2 {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
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 {
|
||||
// Answers stay until the operator dismisses them: a warning's answers must not die in ten seconds.
|
||||
args["expire_ms"] = 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))
|
||||
}
|
||||
@@ -270,7 +270,7 @@ func TestAClearingOfSomethingNeverSaidSaysNothing(t *testing.T) {
|
||||
|
||||
func TestABurstIsOneMessage(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h.Desktop = nil
|
||||
h.Desk = nil
|
||||
for i := 0; i < 5; i++ {
|
||||
h.Condition(EventRaised, cond(fmt.Sprintf("seat.s%d.silent", i), Warning, fmt.Sprintf("seat %d is silent", i)))
|
||||
c.pass(2 * time.Second)
|
||||
@@ -413,7 +413,7 @@ func TestARestartSaysNothingAgain(t *testing.T) {
|
||||
w.At = c.now()
|
||||
h.Condition(EventRaised, w) // held when the holder stops
|
||||
c.pass(5 * time.Second)
|
||||
again := &Holder{Telegram: tg, Desktop: dt, Store: st, Now: c.now, Logf: t.Logf}
|
||||
again := &Holder{Away: tg, Desk: dt, Store: st, Now: c.now, Logf: t.Logf}
|
||||
if err := again.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -89,6 +89,13 @@ type Message struct {
|
||||
Urgent bool
|
||||
Quiet bool // a clearing: shown without urgency
|
||||
Actions []Choice
|
||||
// Reply is the answer this message answers, by its id: the channel shows it where that was given.
|
||||
Reply string
|
||||
// To is the account it is for, when not the one linked: a notice to an account being unlinked, or an
|
||||
// ask put to the account a relink replaces.
|
||||
To string
|
||||
// Secret: shown once, and kept by no channel (a link's code).
|
||||
Secret bool
|
||||
}
|
||||
|
||||
// Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label.
|
||||
@@ -145,7 +152,10 @@ type Record struct {
|
||||
// nothing stays silent for ever.
|
||||
QuietReminded bool `json:"quiet_reminded,omitempty"`
|
||||
// Answered is what the operator chose from a notification, and what came of it.
|
||||
Answered string `json:"answered,omitempty"`
|
||||
Answered string `json:"answered,omitempty"`
|
||||
// Ask is the open ask about this record, `<asker>.<id>`: its options are this record's answers
|
||||
// (novox/hq ADR 0259).
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Origin string `json:"origin"`
|
||||
More string `json:"more"`
|
||||
Raised time.Time `json:"raised"`
|
||||
@@ -212,17 +222,30 @@ type Store interface {
|
||||
Recent() ([]Sent, error)
|
||||
}
|
||||
|
||||
// Holder is the seat's holder.
|
||||
// Holder is the seat's holder: the router (novox/hq ADR 0234 §5, ADR 0259). It keeps no channel's client:
|
||||
// Away is the channel that reaches the operator away from the machines (a kind promising reaches-away),
|
||||
// Desk the one at the machines; each is a kind of the `channel` bench, held by a module of its own.
|
||||
type Holder struct {
|
||||
Telegram Channel
|
||||
Desktop Channel
|
||||
Store Store
|
||||
Away Channel
|
||||
Desk Channel
|
||||
Store Store
|
||||
// Asks keeps the asks, the operator's identities and their links (asks.go, link.go); Bench is what
|
||||
// the controller says of every channel kind and what each says of itself; Publish says on a seat.
|
||||
Asks AskStore
|
||||
Bench *Bench
|
||||
Publish func(subject string, body any, id string) error
|
||||
// Node is the machine this router runs on; StateDir its own directory, where root leaves a break-glass
|
||||
// request (selfact.go).
|
||||
Node string
|
||||
StateDir string
|
||||
Now func() time.Time
|
||||
Logf func(string, ...any)
|
||||
// Emit states a fact as this module (refused); nil states nothing.
|
||||
Emit func(event string, body any) error
|
||||
// Ask calls a seat verb through the mesh, as this module: the answers the operator chooses.
|
||||
Ask Asker
|
||||
// RootFree asks the controller's verb root-free about machines; nil asks it through Ask (askRootFree).
|
||||
RootFree func(machines []string) (map[string]RootVerdict, error)
|
||||
// Zone is the operator's time zone, which every time in a message is said in; nil is this machine's.
|
||||
Zone func() *time.Location
|
||||
|
||||
@@ -255,6 +278,13 @@ type Holder struct {
|
||||
stale int
|
||||
// answered is what came of each answer event, by its id: a redelivered one is acted on once.
|
||||
answered map[string]string
|
||||
// asks are the asks this router holds, by `<asker>.<id>`, and tickets each ask's tickets to it.
|
||||
asks map[string]*askEntry
|
||||
tickets map[string]string
|
||||
// lastUnlink is the last unlink of each kind.
|
||||
lastUnlink map[string]Unlinked
|
||||
// rootWord is the controller's last word on whether an agent can become root, by the machines asked.
|
||||
rootWord map[string]rootWord
|
||||
}
|
||||
|
||||
func (h *Holder) init() {
|
||||
@@ -308,7 +338,10 @@ func (h *Holder) Load() error {
|
||||
if sent, err := h.Store.Recent(); err == nil {
|
||||
h.recent = sent
|
||||
}
|
||||
return nil
|
||||
h.mu.Unlock()
|
||||
err = h.loadAsks()
|
||||
h.mu.Lock()
|
||||
return err
|
||||
}
|
||||
|
||||
// Start marks the holder's start: the grace minute counts from here.
|
||||
@@ -521,6 +554,7 @@ func (h *Holder) cleared(key string, quiet bool) {
|
||||
// Tick does what time asks: reminders, retries, what is held, and forgetting what cleared long
|
||||
// enough ago that a new raise is a new message.
|
||||
func (h *Holder) Tick() {
|
||||
h.warmRootFree(false) // outside the work: what is offered inside it reads a recent word
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
@@ -532,6 +566,8 @@ func (h *Holder) Tick() {
|
||||
}
|
||||
h.mu.Unlock()
|
||||
sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) })
|
||||
h.sweepAsks(now)
|
||||
h.breakGlass()
|
||||
for _, r := range recs {
|
||||
switch {
|
||||
case !r.Cleared.IsZero():
|
||||
@@ -574,6 +610,7 @@ func (h *Holder) Tick() {
|
||||
// Flush sends what is due on each channel; the runtime calls it every few seconds, so a held
|
||||
// message waits its window and not a minute more.
|
||||
func (h *Holder) Flush() {
|
||||
h.warmRootFree(false) // outside the work: what is offered inside it reads a recent word
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
@@ -592,7 +629,73 @@ func remindAfter(severity string) time.Duration {
|
||||
|
||||
// say composes a record's message under the content rule: refused, it is composed again with less of
|
||||
// it, until what remains may leave. The refusal is recorded and stated once per record.
|
||||
func (h *Holder) say(r *Record, what string) Message {
|
||||
func (h *Holder) say(r *Record, what string) Message { return h.sayOn(r, what, "") }
|
||||
|
||||
// sayOn is a record's message on one channel kind: its words, what came of an answer, the answers that kind
|
||||
// can carry, and where the others can be given. An empty kind is the words alone.
|
||||
func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
||||
m := h.words(r, what)
|
||||
if what == "cleared" || m.Title == "A mesh message was withheld" {
|
||||
return m
|
||||
}
|
||||
if r.Answered != "" {
|
||||
m.Body = strings.TrimSpace(r.Answered + "\n" + m.Body)
|
||||
}
|
||||
if kind == "" {
|
||||
return m
|
||||
}
|
||||
withheld := map[string]bool{} // the labels of answers whose effect could not be said
|
||||
if rec := h.openAsk(r); rec != nil {
|
||||
// Who asks, as the bus proves it (the asker is the subject's, never the body's), what each answer does,
|
||||
// and what happens if nobody answers (the review of 2026-10-09).
|
||||
for _, line := range h.askLines(rec) {
|
||||
if _, ok := Check(line); ok {
|
||||
m.Body = strings.TrimSpace(m.Body + "\n" + line)
|
||||
}
|
||||
}
|
||||
// **An answer whose effect is not said is not offered** (the confirmation review of 2026-10-09): its line
|
||||
// refused by the content rule, its button went out all the same, and a tap would authorise what the
|
||||
// person was not shown.
|
||||
for _, o := range rec.Ask.Options {
|
||||
if _, ok := Check(o.Label + ": " + o.Does + "."); !ok || strings.TrimSpace(o.Does) == "" {
|
||||
withheld[o.Label] = true
|
||||
}
|
||||
}
|
||||
if line := h.elsewhere(rec, kind); line != "" {
|
||||
if _, ok := Check(line); ok {
|
||||
m.Body = strings.TrimSpace(m.Body + "\n" + line)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range h.choices(r, kind) {
|
||||
if !withheld[c.Label] {
|
||||
m.Actions = append(m.Actions, c)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// askLines are what an ask's message says of the ask itself: who asks, each option's effect, and its expiry.
|
||||
func (h *Holder) askLines(rec *AskRecord) []string {
|
||||
asker := rec.Asker
|
||||
if asker == SelfAsker {
|
||||
asker = "the router itself"
|
||||
}
|
||||
lines := []string{"Asked by " + asker + "."}
|
||||
for _, o := range rec.Ask.Options {
|
||||
if o.Does != "" {
|
||||
lines = append(lines, o.Label+": "+o.Does+".")
|
||||
}
|
||||
}
|
||||
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
|
||||
lines = append(lines, "If nobody answers by "+rec.Ask.Expires.In(h.zone()).Format("15:04")+": "+rec.Ask.OnExpiry+".")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// words is a record's message under the content rule: refused, it is composed again with less of it,
|
||||
// until what remains may leave. The refusal is recorded and stated once per record.
|
||||
func (h *Holder) words(r *Record, what string) Message {
|
||||
now := h.Now()
|
||||
if r.Refused != "" {
|
||||
// Refused before: its words stay withheld in every later message too.
|
||||
@@ -606,9 +709,6 @@ func (h *Holder) say(r *Record, what string) Message {
|
||||
m := compose(r, what, now, h.zone(), 0)
|
||||
refusal, ok := Check(m.Text())
|
||||
if ok {
|
||||
if what != "cleared" {
|
||||
m.Actions = h.choices(r)
|
||||
}
|
||||
return m
|
||||
}
|
||||
h.mu.Lock()
|
||||
@@ -643,7 +743,7 @@ func (h *Holder) say(r *Record, what string) Message {
|
||||
|
||||
func (h *Holder) channels() []Channel {
|
||||
var out []Channel
|
||||
for _, c := range []Channel{h.Telegram, h.Desktop} {
|
||||
for _, c := range []Channel{h.Away, h.Desk} {
|
||||
if c != nil {
|
||||
out = append(out, c)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type fakeChannel struct {
|
||||
to []string // whom each send was for, by account; "" for the linked one
|
||||
name string
|
||||
notReady error
|
||||
fail error
|
||||
@@ -29,6 +30,7 @@ func (f *fakeChannel) Send(m Message) (string, error) {
|
||||
}
|
||||
f.n++
|
||||
f.sends = append(f.sends, m)
|
||||
f.to = append(f.to, m.To)
|
||||
if f.name == "desktop" {
|
||||
return fmt.Sprintf("g14=%d", f.n), nil // as the desktop answers: the machine and the notification
|
||||
}
|
||||
@@ -108,7 +110,7 @@ func newHolder(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memS
|
||||
tg, dt := &fakeChannel{name: "telegram", silent: true}, &fakeChannel{name: "desktop"}
|
||||
st := &memStore{}
|
||||
var emitted []string
|
||||
h := &Holder{Telegram: tg, Desktop: dt, Store: st, Now: c.now, Logf: t.Logf,
|
||||
h := &Holder{Away: tg, Desk: dt, Store: st, Now: c.now, Logf: t.Logf,
|
||||
Emit: func(e string, _ any) error { emitted = append(emitted, e); return nil }}
|
||||
h.init()
|
||||
return h, tg, dt, c, st
|
||||
@@ -163,7 +165,7 @@ func TestAWarningGoesToTheDesktopWhenASessionAnswersElseTelegram(t *testing.T) {
|
||||
|
||||
func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h.Desktop = nil
|
||||
h.Desk = nil
|
||||
// Urgent conditions, each alone and after a quiet half minute: each is its own message — until
|
||||
// the cap, which is said once, in a message of its own, and holds the rest.
|
||||
for i := 0; i < 25; i++ {
|
||||
@@ -241,7 +243,7 @@ func TestAMessageCarryingAnAddressIsRefusedAndSaidWithItsWordsWithheld(t *testin
|
||||
|
||||
func TestAKeyThatCarriesAnAddressIsWithheldToo(t *testing.T) {
|
||||
h, tg, _, _, _ := newHolder(t)
|
||||
h.Desktop = nil
|
||||
h.Desk = nil
|
||||
h.Condition(EventRaised, cond("machine.10.0.0.7.silent", Urgent, "a machine is silent"))
|
||||
if len(tg.sends) != 1 || strings.Contains(tg.sends[0].Text(), "10.0.0.7") {
|
||||
t.Fatalf("sends: %+v", tg.sends)
|
||||
@@ -250,7 +252,7 @@ func TestAKeyThatCarriesAnAddressIsWithheldToo(t *testing.T) {
|
||||
|
||||
func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h.Desktop = nil
|
||||
h.Desk = nil
|
||||
u := cond("bus.controller.slow-consumer", Urgent, "the controller's consumer is far behind")
|
||||
u.Raised = c.now()
|
||||
w := cond("plan.41.stalled", Warning, "plan 41 waits")
|
||||
@@ -365,7 +367,7 @@ func TestEscalationIsSaidOnce(t *testing.T) {
|
||||
|
||||
func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h.Desktop = nil
|
||||
h.Desk = nil
|
||||
tg.fail = errors.New("telegram sendMessage: cannot connect (dial)")
|
||||
h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "silent"))
|
||||
st := h.Status("listening")
|
||||
@@ -396,11 +398,11 @@ func TestNothingConfiguredIsSaidInTheStatus(t *testing.T) {
|
||||
|
||||
func TestARestartForgetsNothing(t *testing.T) {
|
||||
h, tg, _, c, st := newHolder(t)
|
||||
h.Desktop = nil
|
||||
h.Desk = nil
|
||||
k := cond("machine.ace.silent", Urgent, "silent")
|
||||
k.Raised = c.now()
|
||||
h.Condition(EventRaised, k)
|
||||
again := &Holder{Telegram: tg, Store: st, Now: c.now, Logf: t.Logf}
|
||||
again := &Holder{Away: tg, Store: st, Now: c.now, Logf: t.Logf}
|
||||
if err := again.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -416,7 +418,7 @@ func TestARestartForgetsNothing(t *testing.T) {
|
||||
|
||||
func TestAnUnreadableEventIsToldOnceAnHour(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h.Desktop = nil
|
||||
h.Desk = nil
|
||||
h.Unreadable("mesh-controller.condition-raised", errors.New("no severity"))
|
||||
h.Unreadable("mesh-controller.condition-raised", errors.New("no severity"))
|
||||
settle(h, c)
|
||||
@@ -433,7 +435,7 @@ func TestAnUnreadableEventIsToldOnceAnHour(t *testing.T) {
|
||||
|
||||
func TestNotifyIsKeptApartFromConditions(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h.Desktop = nil
|
||||
h.Desk = nil
|
||||
out, err := h.Notify("backup.ace.failed", Warning, "last night's backup of the home server failed", "backup", false)
|
||||
if err != nil || out["held"] != true {
|
||||
t.Fatalf("%v %v", out, err)
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package main
|
||||
|
||||
// Who the operator is on a channel (novox/hq ADR 0259 §7): the router's list of the operator's
|
||||
// identities, one per kind, changed only by linking and unlinking — never by a setting, which any
|
||||
// principal granted `settings` could write.
|
||||
//
|
||||
// - **Linking:** the operator sends the channel's bot `/start`; the channel says `link.<kind>` with the
|
||||
// sender as its service authenticated it. The router shows a six-digit code on the desk, with the name
|
||||
// the service shows, for ten minutes. The operator sends the code to the bot; the channel asks it as a
|
||||
// proof (`proof.code.<kind>`: request and reply, never kept). When it matches, the account is linked,
|
||||
// and said on every channel.
|
||||
// - **A new link acknowledges at once and approves only after an hour** (LinkedApproveAfter), decided
|
||||
// by the operator on 2026-10-08 until an authenticator-app code exists: the hour lets the operator
|
||||
// unlink an account an agent linked by reading the code off the X11 desk.
|
||||
// - **One identity per kind.** A second link of a kind is refused until the first is unlinked; unlinking
|
||||
// is said on every channel, the unlinked one included.
|
||||
// - Only a kind whose claim declares `verified-sender` can be linked: the desk proves nothing about who
|
||||
// clicked, so it has no identities and answers acknowledgements only.
|
||||
// - The code is kept only as a salted hash; five wrong tries end the link asked for.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
const (
|
||||
// LinkCodeFor is how long a link's code is good for.
|
||||
LinkCodeFor = 10 * time.Minute
|
||||
// LinkTries is how many wrong codes end a link asked for.
|
||||
LinkTries = 5
|
||||
)
|
||||
|
||||
// PendingLink is a link asked for and not yet confirmed: who asked, and the code's hash.
|
||||
type PendingLink struct {
|
||||
Kind string `json:"kind"`
|
||||
Identity string `json:"identity"`
|
||||
Display string `json:"display,omitempty"`
|
||||
Salt string `json:"salt"`
|
||||
CodeHash string `json:"code-hash"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Tries int `json:"tries"`
|
||||
// Confirmed: the code was right, and the link waits for the account it replaces.
|
||||
Confirmed bool `json:"confirmed,omitempty"`
|
||||
}
|
||||
|
||||
func identityKey(kind, identity string) string { return kind + "." + kvKey(identity) }
|
||||
|
||||
func hashCode(salt, code string) string {
|
||||
sum := sha256.Sum256([]byte(salt + ":" + code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// identity is the linked account of a kind, when this sender is it.
|
||||
func (h *Holder) identity(kind, identity string) *Identity {
|
||||
if h.Asks == nil || identity == "" {
|
||||
return nil
|
||||
}
|
||||
id, err := h.Asks.GetIdentity(identityKey(kind, identity))
|
||||
if err != nil || id == nil || id.Kind != kind || id.Identity != identity {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// identitiesOf is every account linked on a kind.
|
||||
func (h *Holder) identitiesOf(kind string) []Identity {
|
||||
if h.Asks == nil {
|
||||
return nil
|
||||
}
|
||||
keys, err := h.Asks.IdentityKeys()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []Identity
|
||||
for _, k := range keys {
|
||||
if !strings.HasPrefix(k, kind+".") {
|
||||
continue
|
||||
}
|
||||
if id, err := h.Asks.GetIdentity(k); err == nil && id != nil && id.Kind == kind {
|
||||
out = append(out, *id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Holder) linked(kind string) bool { return len(h.identitiesOf(kind)) > 0 }
|
||||
|
||||
// LinksAnHour is how many links one account may ask in an hour, and KindLinksAnHour how many all accounts
|
||||
// together may ask of one kind: kept in the router's state, so a restart forgets neither (the review of
|
||||
// 2026-10-09). Per account, so a stranger's tries do not stop the operator's.
|
||||
const (
|
||||
LinksAnHour = 3
|
||||
KindLinksAnHour = 10
|
||||
)
|
||||
|
||||
// allowLink counts a link asked by an account on a kind, and says why not when either bound is reached, or
|
||||
// the count cannot be kept: an uncounted link is not begun.
|
||||
func (h *Holder) allowLink(kind, identity string, now time.Time) string {
|
||||
for _, b := range []struct {
|
||||
key string
|
||||
most int
|
||||
words string
|
||||
}{
|
||||
{"asked." + kind + "." + kvKey(identity), LinksAnHour, fmt.Sprintf("This account asked to be linked %d times this hour. Try again in an hour.", LinksAnHour)},
|
||||
{"asked." + kind, KindLinksAnHour, fmt.Sprintf("Links were asked %d times this hour on %s. Try again in an hour.", KindLinksAnHour, Display(kind))},
|
||||
} {
|
||||
times, err := h.Asks.GetLinksAsked(b.key)
|
||||
if err != nil {
|
||||
return "How often links were asked cannot be read, so none is begun: " + err.Error()
|
||||
}
|
||||
var recent []time.Time
|
||||
for _, t := range times {
|
||||
if now.Sub(t) < time.Hour && now.Sub(t) >= 0 {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
if len(recent) >= b.most {
|
||||
return b.words
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"asked." + kind + "." + kvKey(identity), "asked." + kind} {
|
||||
times, _ := h.Asks.GetLinksAsked(key)
|
||||
var recent []time.Time
|
||||
for _, t := range times {
|
||||
if now.Sub(t) < time.Hour && now.Sub(t) >= 0 {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
if err := h.Asks.PutLinksAsked(key, append(recent, now)); err != nil {
|
||||
return "How often links were asked cannot be kept, so none is begun: " + err.Error()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// offerDeskUnlink asks at the desk whether an account still in its first hour is unlinked: it cannot approve
|
||||
// yet, so whoever is at the desk may undo it (ADR 0259 §7, the review of 2026-10-09). One such question per
|
||||
// account at a time. Answers whether it was asked.
|
||||
func (h *Holder) offerDeskUnlink(id Identity) bool {
|
||||
desk := ""
|
||||
for _, k := range h.Bench.Kinds() {
|
||||
if k.Kind != id.Kind && h.Bench.Promises(k.Kind, "private") && !h.Bench.Promises(k.Kind, "verified-sender") &&
|
||||
h.Bench.Ready(k.Kind) == nil {
|
||||
desk = k.Kind
|
||||
break
|
||||
}
|
||||
}
|
||||
if desk == "" {
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
for _, e := range h.asks {
|
||||
if e.rec.Asker == SelfAsker && e.rec.State == StateOpen && e.rec.Act != nil && e.rec.Act.Do == "unlink" &&
|
||||
e.rec.Act.Identity == id.Identity && e.rec.Only != nil && e.rec.Only.Kind == desk {
|
||||
h.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
now := h.Now()
|
||||
until := id.ApproveFrom
|
||||
a := asks.Ask{ID: "desk-unlink-" + id.Kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink the account linked just now?",
|
||||
Explanation: fmt.Sprintf("The %s account %s (number %s) was linked as you at %s. Another account just asked "+
|
||||
"to be linked. If the linked one is not yours, unlink it here before %s; after that only that account, "+
|
||||
"or root at the router's machine, can.", Display(id.Kind), id.Display, id.Identity,
|
||||
id.Linked.In(h.zone()).Format("15:04"), until.In(h.zone()).Format("15:04")),
|
||||
Who: asks.Operator, Expires: until, OnExpiry: "the account stays linked",
|
||||
Options: []asks.Option{{ID: "unlink", Label: "Unlink it", Does: "that account stops answering as you", Level: asks.Acknowledge}}}
|
||||
if err := h.askSelf(a, Identity{Kind: desk}, SelfAct{Do: "unlink", Kind: id.Kind, Identity: id.Identity}); err != nil {
|
||||
h.Logf("[messenger] the desk could not be asked to unlink %s: %v", id.Display, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Link takes a link asked for on a kind: a code shown where only the operator sees it, or a refusal said on
|
||||
// the kind.
|
||||
func (h *Holder) Link(kind string, l asks.LinkAsked) string {
|
||||
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
reply := func(title, body string) string {
|
||||
_ = h.showOn(kind, Message{Title: title, Body: body, Quiet: true, Reply: l.ID})
|
||||
return title
|
||||
}
|
||||
if h.Bench == nil || !h.Bench.Promises(kind, "verified-sender") {
|
||||
return reply("This channel cannot be linked", "It cannot prove who sends to it, so it answers only what anyone at the desk could.")
|
||||
}
|
||||
if !l.Sender.Authenticated || l.Sender.Identity == "" {
|
||||
return reply("Not linked", "The service did not say who you are.")
|
||||
}
|
||||
now := h.Now()
|
||||
if now.Sub(l.At) > FreshFor {
|
||||
h.Logf("[messenger] a link asked on %s at %s is too old to act on", kind, l.At.Format(time.RFC3339))
|
||||
return "too old"
|
||||
}
|
||||
for _, id := range h.identitiesOf(kind) {
|
||||
if id.Identity == l.Sender.Identity {
|
||||
return reply("Already linked", "This account is already linked as the operator.")
|
||||
}
|
||||
if now.Before(id.ApproveFrom) && h.offerDeskUnlink(id) {
|
||||
return reply("Another account is linked", "Another account was linked here less than an hour ago. If "+
|
||||
"that was not you, unlink it at your desk, where you were just asked, or as root at the router's "+
|
||||
"machine; then send /start again.")
|
||||
}
|
||||
return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked "+
|
||||
"with that account's own approval, or as root at the router's machine, and then this one can be linked.")
|
||||
}
|
||||
if u := h.recentUnlink(kind); u != nil && u.Identity.Identity == l.Sender.Identity {
|
||||
// An account unlinked less than a day ago does not come back by a code at once (the review of
|
||||
// 2026-10-09): whoever unlinked it meant it, and an account taken with a phone must not walk back in.
|
||||
return reply("Not linked", "This account was unlinked less than a day ago. It can be linked again from "+
|
||||
u.At.Add(RelinkWaits).In(h.zone()).Format("2006-01-02 15:04")+", or allowed sooner as root at the router's machine.")
|
||||
}
|
||||
if why := h.allowLink(kind, l.Sender.Identity, now); why != "" {
|
||||
return reply("Not linked", why)
|
||||
}
|
||||
// A code is shown only where the words stay on the operator's machines: a kind declaring `private`.
|
||||
// It is shown now or never: a desk that cannot show it now (its notifications paused, nobody there) begins
|
||||
// no link, so no code waits in a queue to be shown to whoever comes later (the review of 2026-10-09).
|
||||
shows := ""
|
||||
var whyNot []string
|
||||
for _, k := range h.Bench.Kinds() {
|
||||
if k.Kind == kind || !h.Bench.Promises(k.Kind, "private") {
|
||||
continue
|
||||
}
|
||||
if err := h.Bench.Ready(k.Kind); err != nil {
|
||||
whyNot = append(whyNot, err.Error())
|
||||
continue
|
||||
}
|
||||
shows = k.Kind
|
||||
break
|
||||
}
|
||||
if shows == "" {
|
||||
why := "No private place can show the code now."
|
||||
if len(whyNot) > 0 {
|
||||
why = "Your desk cannot show the code now: " + strings.Join(whyNot, "; ") + "."
|
||||
}
|
||||
return reply("Not linked", why+" Nothing was begun. Try again when you are at your machine.")
|
||||
}
|
||||
code, err := sixDigits()
|
||||
if err != nil {
|
||||
return reply("Not linked", "No code could be made.")
|
||||
}
|
||||
salt := ticket()
|
||||
p := PendingLink{Kind: kind, Identity: l.Sender.Identity, Display: l.Sender.Display, Salt: salt,
|
||||
CodeHash: hashCode(salt, code), Expires: now.Add(LinkCodeFor)}
|
||||
if err := h.Asks.PutLink(kind, p); err != nil {
|
||||
return reply("Not linked", "The link could not be recorded: "+err.Error())
|
||||
}
|
||||
who := l.Sender.Display
|
||||
if who == "" {
|
||||
who = "an account"
|
||||
}
|
||||
notice := Message{Title: "Link a " + Display(kind) + " account as you",
|
||||
Body: fmt.Sprintf("The %s account %q (number %s) asked to be linked as you. If that is you, send this code to "+
|
||||
"the bot before %s: %s %s. If it is not you, do nothing: nothing is linked.", Display(kind), who,
|
||||
l.Sender.Identity, p.Expires.In(h.zone()).Format("15:04"), code[:3], code[3:]),
|
||||
Urgent: true, Secret: true}
|
||||
if err := h.showOn(shows, notice); err != nil {
|
||||
_ = h.Asks.DeleteLink(kind)
|
||||
return reply("Not linked", "The code could not be shown: "+err.Error())
|
||||
}
|
||||
h.Logf("[messenger] a link was asked on %s by %q (%s); its code is shown on %s", kind, who, l.Sender.Identity, shows)
|
||||
return reply("Send the code shown on your desk", "Six digits, within ten minutes.")
|
||||
}
|
||||
|
||||
// Prove answers a code sent on a kind: the link it confirms, or why not. Never said in an event.
|
||||
func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
|
||||
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
no := func(words string) asks.ProofAnswer { return asks.ProofAnswer{Accepted: false, Words: words} }
|
||||
if c.Purpose != "" && c.Purpose != "link" {
|
||||
return no("The mesh takes no code for that yet.")
|
||||
}
|
||||
if h.Asks == nil {
|
||||
return no("Nothing can be linked here.")
|
||||
}
|
||||
p, err := h.Asks.GetLink(kind)
|
||||
if err != nil || p == nil || p.Confirmed {
|
||||
return no("No link waits for a code. Send /start to begin.")
|
||||
}
|
||||
now := h.Now()
|
||||
if !now.Before(p.Expires) {
|
||||
_ = h.Asks.DeleteLink(kind)
|
||||
return no("The code expired. Send /start for a new one.")
|
||||
}
|
||||
if c.Sender.Identity != p.Identity || !c.Sender.Authenticated {
|
||||
return no("This code was asked for by another account.")
|
||||
}
|
||||
code := strings.ReplaceAll(strings.TrimSpace(c.Code), " ", "")
|
||||
if subtle.ConstantTimeCompare([]byte(hashCode(p.Salt, code)), []byte(p.CodeHash)) != 1 {
|
||||
p.Tries++
|
||||
if p.Tries >= LinkTries {
|
||||
_ = h.Asks.DeleteLink(kind)
|
||||
h.Logf("[messenger] a link on %s ended after %d wrong codes", kind, p.Tries)
|
||||
return no("That code is not right, and that was the last try. Send /start for a new one.")
|
||||
}
|
||||
_ = h.Asks.PutLink(kind, *p)
|
||||
return no("That code is not right.")
|
||||
}
|
||||
id := Identity{Kind: kind, Identity: p.Identity, Display: p.Display}
|
||||
u := h.recentUnlink(kind)
|
||||
if u != nil && u.Identity.Identity == p.Identity {
|
||||
_ = h.Asks.DeleteLink(kind)
|
||||
return no("This account was unlinked less than a day ago, and is not linked again by a code before a day has passed.")
|
||||
}
|
||||
if others := h.identitiesOf(kind); len(others) > 0 {
|
||||
_ = h.Asks.DeleteLink(kind)
|
||||
return no("Another account was linked here meanwhile. Nothing was linked.")
|
||||
}
|
||||
if u != nil {
|
||||
// Within a day of an unlink: the account it replaces decides (novox/hq ADR 0259 §7).
|
||||
p.Confirmed = true
|
||||
p.Expires = now.Add(RelinkWaits)
|
||||
_ = h.Asks.PutLink(kind, *p)
|
||||
a := asks.Ask{ID: "relink-" + kind + "-" + fmt.Sprint(now.Unix()),
|
||||
Headline: "Allow another account in your place?",
|
||||
Explanation: fmt.Sprintf("This account was unlinked from being you less than a day ago. The %s account %s "+
|
||||
"(number %s) now asks to be linked as you. If you choose Allow, it is.", Display(kind), p.Display, p.Identity),
|
||||
Who: asks.Operator, Expires: now.Add(RelinkWaits - time.Minute), OnExpiry: "nothing is linked",
|
||||
Options: []asks.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: asks.Approve}}}
|
||||
prior := u.Identity
|
||||
prior.ApproveFrom = prior.Linked // an account linked before: its hour passed while it was linked
|
||||
if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Identity: id.Identity, Link: &id}); err != nil {
|
||||
return no("The link could not be asked of the account it replaces: " + err.Error())
|
||||
}
|
||||
h.announce(Message{Title: "A new " + Display(kind) + " account asks to be linked as you",
|
||||
Body: fmt.Sprintf("%s (number %s), less than a day after an unlink. It waits for the account it replaces, "+
|
||||
"or for root at the router's machine.", p.Display, p.Identity), Urgent: true})
|
||||
return asks.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " +
|
||||
"than a day ago, the link waits for that account's approval, or for you at the router's machine."}
|
||||
}
|
||||
h.linkNow(id, "with the code shown on the desk")
|
||||
from := now.Add(LinkedApproveAfter).In(h.zone()).Format("15:04")
|
||||
return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
|
||||
}
|
||||
|
||||
// Identities is every linked account, by kind.
|
||||
func (h *Holder) Identities() []Identity {
|
||||
if h.Asks == nil {
|
||||
return nil
|
||||
}
|
||||
keys, _ := h.Asks.IdentityKeys()
|
||||
var out []Identity
|
||||
for _, k := range keys {
|
||||
if id, err := h.Asks.GetIdentity(k); err == nil && id != nil {
|
||||
out = append(out, *id)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Kind+out[i].Identity < out[j].Kind+out[j].Identity })
|
||||
return out
|
||||
}
|
||||
|
||||
// announce says a message on every kind that can send, at once: a link or an unlink is seen everywhere.
|
||||
func (h *Holder) announce(m Message) {
|
||||
if h.Bench == nil {
|
||||
return
|
||||
}
|
||||
for _, k := range h.Bench.Kinds() {
|
||||
if h.Bench.Ready(k.Kind) == nil {
|
||||
_ = h.showOn(k.Kind, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sixDigits() (string, error) {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%06d", n.Int64()), nil
|
||||
}
|
||||
|
||||
// decodeLink reads a link asked for.
|
||||
func decodeLink(body json.RawMessage) (asks.LinkAsked, error) {
|
||||
var l asks.LinkAsked
|
||||
return l, json.Unmarshal(body, &l)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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
|
||||
// messenger: the holder of the operator-channel seat, the router (novox/hq to-be 45 §5, to-be 46, ADR
|
||||
// 0227, ADR 0259). A Go bundle the node's runtime launches. It consumes the controller's condition events and
|
||||
// decides what is said to the operator, on the channel kinds the mesh holds — each a module of its own — and
|
||||
// holds every ask, judges each answer and issues the warrant its asker acts on. 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
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
|
||||
@@ -25,8 +26,6 @@ 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"`
|
||||
@@ -54,27 +53,13 @@ func readSettings(path string) (Settings, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -137,6 +122,102 @@ func (stateStore) Recent() ([]Sent, error) {
|
||||
return s, json.Unmarshal(e.Value, &s)
|
||||
}
|
||||
|
||||
// askStore keeps the asks in the state `asks` and the operator's identities, and the links asked for, in
|
||||
// `identities` (novox/hq ADR 0259). Each write that must win once is a compare-and-set.
|
||||
type askStore struct{}
|
||||
|
||||
// changed reads the runtime's refusal of a compare-and-set that lost, told by its code, as ErrChanged.
|
||||
func changed(err error) error {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return ErrChanged
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (askStore) GetAsk(key string) (*AskRecord, uint64, error) {
|
||||
e, err := stdio.State("asks").Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var r AskRecord
|
||||
if err := json.Unmarshal(e.Value, &r); err != nil {
|
||||
return nil, 0, fmt.Errorf("the ask kept as %s cannot be read: %v", key, err)
|
||||
}
|
||||
return &r, e.Revision, nil
|
||||
}
|
||||
func (askStore) CreateAsk(key string, r AskRecord) (uint64, error) {
|
||||
rev, err := stdio.State("asks").Create(key, r)
|
||||
return rev, changed(err)
|
||||
}
|
||||
func (askStore) UpdateAsk(key string, r AskRecord, revision uint64) (uint64, error) {
|
||||
rev, err := stdio.State("asks").Update(key, r, revision)
|
||||
return rev, changed(err)
|
||||
}
|
||||
func (askStore) AskKeys() ([]string, error) { return stdio.State("asks").Keys() }
|
||||
func (askStore) GetIdentity(key string) (*Identity, error) {
|
||||
e, err := stdio.State("identities").Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
var id Identity
|
||||
return &id, json.Unmarshal(e.Value, &id)
|
||||
}
|
||||
func (askStore) PutIdentity(key string, id Identity) error {
|
||||
_, err := stdio.State("identities").Put(key, id)
|
||||
return err
|
||||
}
|
||||
func (askStore) DeleteIdentity(key string) error { return stdio.State("identities").Delete(key) }
|
||||
func (askStore) IdentityKeys() ([]string, error) {
|
||||
keys, err := stdio.State("identities").Keys()
|
||||
var out []string
|
||||
for _, k := range keys {
|
||||
if !strings.HasPrefix(k, "pending.") && !strings.HasPrefix(k, "unlinked.") && !strings.HasPrefix(k, "asked.") {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
func (askStore) GetLink(kind string) (*PendingLink, error) {
|
||||
e, err := stdio.State("identities").Get("pending." + kind)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
var l PendingLink
|
||||
return &l, json.Unmarshal(e.Value, &l)
|
||||
}
|
||||
func (askStore) PutLink(kind string, l PendingLink) error {
|
||||
_, err := stdio.State("identities").Put("pending."+kind, l)
|
||||
return err
|
||||
}
|
||||
func (askStore) DeleteLink(kind string) error {
|
||||
return stdio.State("identities").Delete("pending." + kind)
|
||||
}
|
||||
func (askStore) GetUnlinked(kind string) (*Unlinked, error) {
|
||||
e, err := stdio.State("identities").Get("unlinked." + kind)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
var u Unlinked
|
||||
return &u, json.Unmarshal(e.Value, &u)
|
||||
}
|
||||
func (askStore) PutUnlinked(kind string, u Unlinked) error {
|
||||
_, err := stdio.State("identities").Put("unlinked."+kind, u)
|
||||
return err
|
||||
}
|
||||
|
||||
func (askStore) GetLinksAsked(key string) ([]time.Time, error) {
|
||||
e, err := stdio.State("identities").Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
var times []time.Time
|
||||
return times, json.Unmarshal(e.Value, ×)
|
||||
}
|
||||
func (askStore) PutLinksAsked(key string, times []time.Time) error {
|
||||
_, err := stdio.State("identities").Put(key, times)
|
||||
return err
|
||||
}
|
||||
|
||||
// listening is whether condition events reach this holder, in words.
|
||||
type listening struct {
|
||||
mu sync.Mutex
|
||||
@@ -163,18 +244,19 @@ func main() {
|
||||
}
|
||||
return s
|
||||
}
|
||||
bench := NewBench(time.Now)
|
||||
publish := func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) }
|
||||
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,
|
||||
Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish},
|
||||
Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish},
|
||||
Store: stateStore{},
|
||||
Asks: askStore{},
|
||||
Node: os.Getenv("MESH_NODE"),
|
||||
StateDir: os.Getenv("MESH_MESSENGER_STATE"),
|
||||
Bench: bench,
|
||||
Publish: publish,
|
||||
Ask: stdio.Ask,
|
||||
Logf: logf,
|
||||
Zone: func() *time.Location {
|
||||
loc, err := zoneOf(settings().TimeZone)
|
||||
said.Lock()
|
||||
@@ -190,6 +272,12 @@ func main() {
|
||||
},
|
||||
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
|
||||
}
|
||||
bench.To = func(kind string) string {
|
||||
if ids := h.identitiesOf(kind); len(ids) > 0 {
|
||||
return ids[0].Identity
|
||||
}
|
||||
return ""
|
||||
}
|
||||
h.init()
|
||||
l := &listening{now: "not yet: starting"}
|
||||
go run(h, l)
|
||||
@@ -250,7 +338,10 @@ func run(h *Holder, l *listening) {
|
||||
h.Condition(event, c)
|
||||
return nil
|
||||
}
|
||||
go listenForAnswers(h)
|
||||
go listenForChannels(h)
|
||||
go takeAsks(h)
|
||||
go answerProofs(h)
|
||||
go followKinds(h)
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.Subscribe(ControllerSeat+".*", handle)
|
||||
if err == nil {
|
||||
@@ -264,61 +355,137 @@ func run(h *Holder, l *listening) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
// kindOf is an intake event's kind and what it is: `intake.<what>.<kind>`, the kind the subject's last token,
|
||||
// which the bus grants that kind's holder alone.
|
||||
func kindOf(key string) (what, kind string, ok bool) {
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) != 3 || parts[0] != asks.IntakeSeat {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[1], parts[2], true
|
||||
}
|
||||
|
||||
// listenForAnswers takes the answers the operator chooses on the desk, retried until it can.
|
||||
func listenForAnswers(h *Holder) {
|
||||
// listenForChannels takes what the channels say (novox/hq ADR 0259): answers chosen, links asked, messages
|
||||
// that failed, and whether each can send — every one under the kind the bus granted its holder.
|
||||
func listenForChannels(h *Holder) {
|
||||
handle := func(e stdio.Envelope) error {
|
||||
if e.Key == StartedEvent {
|
||||
var body struct {
|
||||
Machine string `json:"machine"`
|
||||
what, kind, ok := kindOf(e.Key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch what {
|
||||
case asks.Chosen:
|
||||
var c asks.Choice
|
||||
if err := json.Unmarshal(e.Body, &c); err != nil || c.Ticket == "" {
|
||||
logf("[messenger] an answer from %s could not be read: %s", kind, firstLineOf(string(e.Body)))
|
||||
return nil
|
||||
}
|
||||
_ = json.Unmarshal(e.Body, &body)
|
||||
if body.Machine == "" {
|
||||
body.Machine = e.Node
|
||||
if c.ID == "" {
|
||||
c.ID = e.Headers["x-event-id"]
|
||||
}
|
||||
_, _ = h.Chosen(kind, c)
|
||||
case asks.Link:
|
||||
l, err := decodeLink(e.Body)
|
||||
if err != nil {
|
||||
logf("[messenger] a link asked on %s could not be read", kind)
|
||||
return nil
|
||||
}
|
||||
h.Link(kind, l)
|
||||
case asks.FailedWhat:
|
||||
var f asks.Failed
|
||||
if json.Unmarshal(e.Body, &f) == nil {
|
||||
h.Failed(kind, f)
|
||||
}
|
||||
case asks.StandingWhat:
|
||||
var s asks.Standing
|
||||
if json.Unmarshal(e.Body, &s) == nil {
|
||||
h.Bench.Stand(kind, Standing{Ready: s.Ready, Why: s.Why, EditsSilently: s.EditsSilently, At: s.At})
|
||||
}
|
||||
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
|
||||
if err := stdio.Subscribe(asks.IntakeSeat+".#", handle); err == nil {
|
||||
logf("[messenger] listening for what the channels say")
|
||||
break
|
||||
} else {
|
||||
logf("[messenger] not hearing the desk's answers yet (%v); asking again in %s", err, wait)
|
||||
logf("[messenger] not hearing the channels yet (%v); asking again in %s", err, wait)
|
||||
}
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// takeAsks takes the asks and cancels submitted to the seat, each under its asker's name.
|
||||
func takeAsks(h *Holder) {
|
||||
handle := func(w stdio.Work) error {
|
||||
parts := strings.Split(w.Subject, ".")
|
||||
if len(parts) != 6 {
|
||||
logf("[messenger] work on %s is not an ask; taken and dropped", w.Subject)
|
||||
return nil
|
||||
}
|
||||
return h.TakeAsk(parts[4], parts[5], w.Body)
|
||||
}
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
if err := stdio.SeatTake(AsksWorker, handle); err == nil {
|
||||
logf("[messenger] taking the asks submitted to %s", asks.Seat)
|
||||
break
|
||||
} else {
|
||||
logf("[messenger] not taking asks yet (%v); asking again in %s", err, wait)
|
||||
}
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// AsksWorker is the worker the seat's asks are taken from, as the controller names it.
|
||||
const AsksWorker = "SEAT_OPERATOR_CHANNEL_worker"
|
||||
|
||||
// answerProofs answers the codes the channels ask as proofs: never an event, never kept.
|
||||
func answerProofs(h *Holder) {
|
||||
handle := func(subject string, body json.RawMessage) (any, error) {
|
||||
parts := strings.Split(subject, ".")
|
||||
kind := parts[len(parts)-1]
|
||||
var c asks.Code
|
||||
if err := json.Unmarshal(body, &c); err != nil {
|
||||
return asks.ProofAnswer{Words: "The code could not be read."}, nil
|
||||
}
|
||||
return h.Prove(kind, c), nil
|
||||
}
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
if err := stdio.SeatAnswer(asks.ProofSubject(asks.CodeProof, "*"), handle); err == nil {
|
||||
logf("[messenger] answering the codes the channels are sent")
|
||||
break
|
||||
} else {
|
||||
logf("[messenger] not answering codes yet (%v); asking again in %s", err, wait)
|
||||
}
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// followKinds reads, every half minute, which channel kinds the controller says exist and what each
|
||||
// promises: never from a channel.
|
||||
func followKinds(h *Holder) {
|
||||
said := ""
|
||||
for {
|
||||
kinds, err := stdio.SeatKinds()
|
||||
if err != nil {
|
||||
if err.Error() != said {
|
||||
logf("[messenger] which channels exist cannot be read (%v); asking again", err)
|
||||
said = err.Error()
|
||||
}
|
||||
} else {
|
||||
var out []KindInfo
|
||||
for _, k := range kinds {
|
||||
if k.Seat == asks.ChannelSeat {
|
||||
out = append(out, KindInfo{Kind: k.Kind, Module: k.Module, Node: k.Node, Capabilities: k.Capabilities})
|
||||
}
|
||||
}
|
||||
h.Bench.SetKinds(out)
|
||||
said = ""
|
||||
}
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// syncFromController reads the controller's open conditions and gives them to the holder.
|
||||
func syncFromController(h *Holder) {
|
||||
asked := h.Now()
|
||||
@@ -398,34 +565,40 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
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"}},
|
||||
Description: "Whether the operator can be reached, and why not: each channel kind the mesh holds — can it " +
|
||||
"send, what it said of itself, 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.",
|
||||
Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }},
|
||||
{Name: "operator-channel.asks",
|
||||
Description: "The asks this router holds (novox/hq ADR 0259): open first, then the last month's — each " +
|
||||
"one's asker, headline, state, when it opened and expires, the channels it was offered on, how it " +
|
||||
"ended (the warrant: who chose what, through which channel), and whether its asker was told.",
|
||||
Run: func(map[string]any) (any, error) { return h.AsksNow(), nil }},
|
||||
{Name: "messenger_identities",
|
||||
Description: "The accounts linked as the operator, one per channel kind: the account, the name its " +
|
||||
"service shows, when it was linked, and from when it can approve (an hour after linking).",
|
||||
Run: func(map[string]any) (any, error) { return h.Identities(), nil }},
|
||||
{Name: "messenger_unlink",
|
||||
Description: "Unlink the account linked as the operator on one channel kind: from then its answers are " +
|
||||
"refused until an account is linked again. Said on every channel, the unlinked one included.",
|
||||
Input: map[string]any{"kind": str("the channel kind, e.g. telegram")},
|
||||
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
|
||||
}
|
||||
kind := strArg(a, "kind")
|
||||
if kind == "" {
|
||||
return nil, errorf("kind is required")
|
||||
}
|
||||
return st, nil
|
||||
return h.Unlink(kind)
|
||||
}},
|
||||
{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.",
|
||||
Description: "Send a test message now, to one channel kind or every one (default), through the content rule " +
|
||||
"and the cap: proves a channel reaches the operator. Answers per channel: put on its queue, or why not.",
|
||||
Input: map[string]any{
|
||||
"channel": map[string]any{"type": "string", "enum": []string{"telegram", "desktop", "both"}},
|
||||
"channel": str("a channel kind, e.g. telegram, or empty for every one"),
|
||||
"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 }},
|
||||
|
||||
@@ -8,31 +8,43 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The manifest says what the code does: the seat it declares and holds and the verbs it serves, the
|
||||
// events it consumes (the controller's three), the one it emits, the tool it calls, its state, its
|
||||
// secret, and its own tools — and names nothing of one installation.
|
||||
// The manifest says what the code does (novox/hq ADR 0259): the seats it declares — the operator's, and
|
||||
// the two kinded benches its channels hold — the one it holds and the verbs it serves, the bench it sends
|
||||
// on, the events it consumes, the one it emits, the tool it calls, its state, and its own tools. It holds
|
||||
// no channel's secret, and names nothing of one installation.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Seats []seat `json:"seats"`
|
||||
Claims []seat `json:"claims"`
|
||||
Consumes []string
|
||||
Emits []string
|
||||
Invokes []string
|
||||
State []string
|
||||
Own map[string]string `json:"own-secrets"`
|
||||
Tools []string
|
||||
Build struct {
|
||||
Module string `json:"module"`
|
||||
Seats []seat `json:"seats"`
|
||||
Claims []seat `json:"claims"`
|
||||
Uses []string
|
||||
Consumes []string
|
||||
Emits []string
|
||||
Invokes []string
|
||||
State []json.RawMessage
|
||||
Own map[string]string `json:"own-secrets"`
|
||||
RunsAs string `json:"runs-as"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
Tools []string
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type seat struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
Accepts []string `json:"accepts"`
|
||||
Emits []string `json:"emits"`
|
||||
Kinded bool `json:"kinded"`
|
||||
ByCaller []string `json:"by-caller"`
|
||||
Proofs []string `json:"proofs"`
|
||||
Records []string `json:"records"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
@@ -48,29 +60,51 @@ func readManifest(t *testing.T) (manifest, string) {
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestItDeclaresAndHoldsTheOperatorChannel(t *testing.T) {
|
||||
func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
want := seat{Name: "operator-channel", Scope: "mesh", Serves: []string{"open", "history", "notify"}}
|
||||
if len(m.Seats) != 1 || !reflect.DeepEqual(m.Seats[0], want) || len(m.Claims) != 1 || !reflect.DeepEqual(m.Claims[0], want) {
|
||||
t.Fatalf("seats %+v claims %+v", m.Seats, m.Claims)
|
||||
verbs := []string{"open", "history", "notify", "asks"}
|
||||
want := []seat{
|
||||
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
|
||||
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}},
|
||||
{Name: asks.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{asks.Show, asks.Edit, asks.Send}},
|
||||
{Name: asks.IntakeSeat, Scope: "mesh", Kinded: true,
|
||||
Emits: []string{asks.Chosen, asks.Link, asks.FailedWhat, asks.StandingWhat}, Proofs: []string{asks.CodeProof}},
|
||||
}
|
||||
if !reflect.DeepEqual(m.Seats, want) {
|
||||
t.Fatalf("seats %+v", m.Seats)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != asks.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) {
|
||||
t.Fatalf("claims %+v", m.Claims)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Uses, []string{asks.ChannelSeat}) {
|
||||
t.Fatalf("uses %v", m.Uses)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, []string{
|
||||
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared,
|
||||
AnswerEvent, StartedEvent}) {
|
||||
"intake.choice", "intake.link", "intake.failed", "intake.standing"}) {
|
||||
t.Fatalf("consumes %v", m.Consumes)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions"}) {
|
||||
// It performs nothing: the verbs it calls read the open conditions and whether root is free.
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions",
|
||||
"seat:" + ControllerSeat + ".root-free"}) {
|
||||
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
|
||||
}
|
||||
if !reflect.DeepEqual(m.State, []string{"open", "sent"}) {
|
||||
t.Fatalf("state %v", m.State)
|
||||
var state []string
|
||||
for _, s := range m.State {
|
||||
var name string
|
||||
if json.Unmarshal(s, &name) != nil {
|
||||
var full struct{ Name string }
|
||||
_ = json.Unmarshal(s, &full)
|
||||
name = full.Name
|
||||
}
|
||||
state = append(state, name)
|
||||
}
|
||||
if m.Own["telegram-token"] == "" {
|
||||
t.Fatalf("own secrets %v", m.Own)
|
||||
if !reflect.DeepEqual(state, []string{"open", "sent", "asks", "identities"}) {
|
||||
t.Fatalf("state %v", state)
|
||||
}
|
||||
env, _ := m.Build.Artifacts[0]["env"].(map[string]any)
|
||||
if env["MESH_MESSENGER_TELEGRAM_TOKEN_FILE"] != m.Own["telegram-token"] {
|
||||
t.Fatalf("the bundle reads its token from %v, the secret is at %v", env["MESH_MESSENGER_TELEGRAM_TOKEN_FILE"], m.Own["telegram-token"])
|
||||
// It holds no channel's secret: only its own bus account, read by its own account alone (ADR 0259 §8).
|
||||
if len(m.Own) != 1 || m.Own["broker"] == "" || m.RunsAs != "messenger" || m.SecretsOwner != "messenger" {
|
||||
t.Fatalf("own secrets %v, runs as %q, secrets owned by %q", m.Own, m.RunsAs, m.SecretsOwner)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +133,7 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
if !reflect.DeepEqual(verbs, m.Claims[0].Serves) {
|
||||
t.Fatalf("verbs %v, claim %v", verbs, m.Claims[0].Serves)
|
||||
}
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox", "zurag", "api.telegram"} {
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox", "zurag", "telegram"} {
|
||||
if strings.Contains(strings.ToLower(raw), never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,15 @@ func (h *Holder) deliver(r *Record, what string) {
|
||||
return
|
||||
}
|
||||
var to []Channel
|
||||
if r.Severity == Urgent {
|
||||
if kinds := h.askKinds(r); len(kinds) > 0 {
|
||||
// An ask goes to every channel that carries one of its answers (novox/hq ADR 0259): the desk for an
|
||||
// acknowledgement, the channel that proves its sender for an approval — whatever its severity.
|
||||
for _, ch := range h.channels() {
|
||||
if kinds[ch.Name()] && ch.Ready() == nil {
|
||||
to = append(to, ch)
|
||||
}
|
||||
}
|
||||
} else if r.Severity == Urgent {
|
||||
for _, ch := range h.channels() {
|
||||
if ch.Ready() == nil {
|
||||
to = append(to, ch)
|
||||
@@ -41,10 +49,10 @@ func (h *Holder) deliver(r *Record, what string) {
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case h.Desktop != nil && h.Desktop.Ready() == nil:
|
||||
to = []Channel{h.Desktop}
|
||||
case h.Telegram != nil && h.Telegram.Ready() == nil:
|
||||
to = []Channel{h.Telegram}
|
||||
case h.Desk != nil && h.Desk.Ready() == nil:
|
||||
to = []Channel{h.Desk}
|
||||
case h.Away != nil && h.Away.Ready() == nil:
|
||||
to = []Channel{h.Away}
|
||||
}
|
||||
}
|
||||
if len(to) == 0 {
|
||||
@@ -113,7 +121,6 @@ func (h *Holder) queued(key string) bool {
|
||||
// edit fails, or that said it inside a digest, is given a new message instead — held like any other.
|
||||
// silentOnly: only where an edit notifies nobody; elsewhere nothing.
|
||||
func (h *Holder) edit(r *Record, what string, silentOnly bool) {
|
||||
m := h.say(r, what)
|
||||
h.mu.Lock()
|
||||
sent := map[string]string{}
|
||||
for k, v := range r.Sent {
|
||||
@@ -131,7 +138,7 @@ func (h *Holder) edit(r *Record, what string, silentOnly bool) {
|
||||
continue
|
||||
}
|
||||
if id != "" && ch.CanEdit() {
|
||||
err := ch.Edit(id, m)
|
||||
err := ch.Edit(id, h.sayOn(r, what, ch.Name()))
|
||||
h.noteChannel(ch.Name(), err)
|
||||
if err == nil {
|
||||
h.record(Sent{At: now, Channel: ch.Name(), Key: r.Key, What: what, Outcome: "edited"})
|
||||
@@ -204,14 +211,14 @@ func (h *Holder) due(ch Channel, list []item, now time.Time) bool {
|
||||
if now.Sub(oldest) < BurstWindow {
|
||||
return false
|
||||
}
|
||||
if ch == h.Desktop && !lastWarn.IsZero() && now.Sub(lastWarn) < DesktopWarningEvery {
|
||||
if ch == h.Desk && !lastWarn.IsZero() && now.Sub(lastWarn) < DesktopWarningEvery {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// line is what an item says, resolved against the record now: nil when it is no longer to be said.
|
||||
func (h *Holder) resolve(it item, now time.Time) (*Record, *Message) {
|
||||
func (h *Holder) resolve(it item, now time.Time, kind string) (*Record, *Message) {
|
||||
if it.Msg != nil {
|
||||
return nil, it.Msg
|
||||
}
|
||||
@@ -224,7 +231,7 @@ func (h *Holder) resolve(it item, now time.Time) (*Record, *Message) {
|
||||
if (it.What == "cleared") != !r.Cleared.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
m := h.say(r, it.What)
|
||||
m := h.sayOn(r, it.What, kind)
|
||||
return r, &m
|
||||
}
|
||||
|
||||
@@ -238,7 +245,7 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
|
||||
}
|
||||
var entries []entry
|
||||
for _, it := range list {
|
||||
r, m := h.resolve(it, now)
|
||||
r, m := h.resolve(it, now, name)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
@@ -280,10 +287,12 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
|
||||
return
|
||||
}
|
||||
if len(entries) > 1 {
|
||||
// A message that asks the operator something goes alone, so its answers come with it.
|
||||
// A message that asks the operator something goes alone, so its answers come with it: one with several
|
||||
// answers, and one carrying an ask's answer even when only one can be given here (the lab's proof found
|
||||
// an ask whose approval was not offered folded into a digest, and its one answer lost with it).
|
||||
var alone, rest []entry
|
||||
for _, e := range entries {
|
||||
if len(e.msg.Actions) > 1 {
|
||||
if len(e.msg.Actions) > 1 || (len(e.msg.Actions) > 0 && e.r != nil && e.r.Ask != "") {
|
||||
alone = append(alone, e)
|
||||
} else {
|
||||
rest = append(rest, e)
|
||||
@@ -341,7 +350,7 @@ func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool {
|
||||
h.lastSend[name] = now
|
||||
h.lastTry[name] = time.Time{}
|
||||
for _, e := range entries {
|
||||
if ch == h.Desktop && !e.it.urgent() {
|
||||
if ch == h.Desk && !e.it.urgent() {
|
||||
// The desktop's warning rhythm counts from a message that carried a warning.
|
||||
h.lastWarn[name] = now
|
||||
}
|
||||
@@ -404,14 +413,14 @@ func (h *Holder) failed(ch Channel, list []item, taken int, now time.Time, err e
|
||||
h.lastTry[name] = now
|
||||
h.mu.Unlock()
|
||||
h.record(Sent{At: now, Channel: name, Key: "(held)", What: fmt.Sprintf("%d held", taken), Outcome: "failed: " + err.Error()})
|
||||
if ch == h.Desktop && h.Telegram != nil && h.Telegram.Ready() == nil {
|
||||
if ch == h.Desk && h.Away != nil && h.Away.Ready() == nil {
|
||||
var keep []item
|
||||
for _, it := range list[:taken] {
|
||||
if it.Severity == Urgent {
|
||||
keep = append(keep, it)
|
||||
continue
|
||||
}
|
||||
h.enqueue(h.Telegram.Name(), it)
|
||||
h.enqueue(h.Away.Name(), it)
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.outbox[name] = append(keep, h.outbox[name][taken:]...)
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
package main
|
||||
|
||||
// The findings of the review of 2026-10-09, each held here by a test that failed before its fix.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// H3: approval rests on the controller's positive, recent word that no agent can become root where the router
|
||||
// and the channel run (its verb root-free), asked live. Every other answer fails closed.
|
||||
func TestApprovalNeedsTheControllersWordThatRootIsFree(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
setup func(h *Holder)
|
||||
want string
|
||||
}{
|
||||
"a router that does not know its machine": {func(h *Holder) { h.Node = "" }, "does not know which machine"},
|
||||
"a channel whose machine is not said": {func(h *Holder) {
|
||||
k, _ := h.Bench.Kind("telegram")
|
||||
k.Node = ""
|
||||
h.Bench.SetKinds([]KindInfo{k})
|
||||
}, "has not said which machine"},
|
||||
"a controller that does not answer": {func(h *Holder) {
|
||||
h.RootFree = func([]string) (map[string]RootVerdict, error) { return nil, errors.New("no responders") }
|
||||
}, "could not be asked: no responders"},
|
||||
"a machine the answer leaves out": {func(h *Holder) {
|
||||
h.RootFree = func([]string) (map[string]RootVerdict, error) { return map[string]RootVerdict{}, nil }
|
||||
}, "said nothing of whether an agent can become root on anchor"},
|
||||
"a machine not free": {func(h *Holder) { rootFreeOf[h]["anchor"] = "the agent account is not judged" },
|
||||
"may become root on anchor"},
|
||||
"the router's own machine not free": {func(h *Holder) {
|
||||
h.Node = "router-machine"
|
||||
rootFreeOf[h]["router-machine"] = "execute is served there"
|
||||
}, "may become root on router-machine"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
h, tg, _, c, store, w := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
release := ticketFor(tg.sends[0], "Release")
|
||||
if release == "" {
|
||||
t.Fatal("not offered while root was free")
|
||||
}
|
||||
tc.setup(h)
|
||||
out, err := h.Chosen("telegram", choiceOf(release, "42", c.now()))
|
||||
if out != "refused" || err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("%s %v, want refused for %q", out, err, tc.want)
|
||||
}
|
||||
if len(w.of("mesh-delivery")) != 0 {
|
||||
t.Error("a warrant was issued")
|
||||
}
|
||||
if got := h.carriers(asks.Approve); len(got) != 0 {
|
||||
t.Errorf("approval still carried on %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// H3: the word is asked of the controller's verb, as this module, and read from its answer.
|
||||
func TestTheRootWordIsAskedOfTheControllersVerb(t *testing.T) {
|
||||
h, _, _, _, _, _ := newRouter(t)
|
||||
h.RootFree = nil
|
||||
var asked []string
|
||||
h.Ask = func(key string, body any) (json.RawMessage, error) {
|
||||
asked = append(asked, key)
|
||||
raw, _ := json.Marshal(body)
|
||||
if !strings.Contains(string(raw), `"machines":["anchor"]`) {
|
||||
t.Errorf("asked about %s", raw)
|
||||
}
|
||||
return json.RawMessage(`{"machines":[{"machine":"anchor","free":true,"why":"judged","judged":"2026-10-09T10:00:00Z"}]}`), nil
|
||||
}
|
||||
info, _ := h.Bench.Kind("telegram")
|
||||
if why := h.rootFree(info, false); why != "" {
|
||||
t.Fatalf("free not read: %s", why)
|
||||
}
|
||||
if len(asked) != 1 || asked[0] != "seat:mesh-controller.root-free" {
|
||||
t.Errorf("asked %v", asked)
|
||||
}
|
||||
h.Ask = func(string, any) (json.RawMessage, error) { return json.RawMessage(`not json`), nil }
|
||||
if why := h.rootFree(info, false); !strings.Contains(why, "could not be read") {
|
||||
t.Errorf("an unreadable answer was a pass: %q", why)
|
||||
}
|
||||
h.Ask = nil
|
||||
if why := h.rootFree(info, false); why == "" {
|
||||
t.Error("no way to ask was a pass")
|
||||
}
|
||||
}
|
||||
|
||||
// H3: a message rendered while root is not free offers no approval; a word kept for rendering is never the
|
||||
// word an answer is judged on.
|
||||
func TestAMessageRenderedWhileRootIsNotFreeOffersNoApproval(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
rec := h.openAsk(h.open["ask.mesh-delivery.d-1"])
|
||||
if got := h.offers(rec, "telegram"); len(got) != 3 {
|
||||
t.Fatalf("offers %v", got)
|
||||
}
|
||||
rootFreeOf[h]["anchor"] = "not judged"
|
||||
c.pass(RootFreeKept)
|
||||
if got := h.offers(rec, "telegram"); len(got) != 0 {
|
||||
t.Errorf("rendered with an approval while root is not free: %v", got)
|
||||
}
|
||||
// Free again, but the kept word is not: the answer asks anew and is taken.
|
||||
delete(rootFreeOf[h], "anchor")
|
||||
out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now()))
|
||||
if err != nil || out != "chosen" {
|
||||
t.Errorf("judged on a kept word: %s %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// H3: a capability the controller's word no longer says stays no longer than the word is fresh: a router that
|
||||
// cannot read the kinds again promises no verified sender.
|
||||
func TestAStaleWordOnTheKindsPromisesNoVerifiedSender(t *testing.T) {
|
||||
h, _, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
if !h.Bench.Promises("telegram", "verified-sender") {
|
||||
t.Fatal("not promised while fresh")
|
||||
}
|
||||
c.pass(KindsFresh + time.Second)
|
||||
h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
if h.Bench.Promises("telegram", "verified-sender") || len(h.carriers(asks.Approve)) != 0 {
|
||||
t.Error("a stale word still promises a verified sender")
|
||||
}
|
||||
if !h.Bench.Promises("telegram", "choice") {
|
||||
t.Error("a stale word stopped the channel sending")
|
||||
}
|
||||
}
|
||||
|
||||
// C6: a link's code is marked secret on every path to a channel, the desk's own role included, so the channel
|
||||
// keeps no copy (the review found it dropped where the desk's role sends).
|
||||
func TestALinksCodeIsSecretOnEveryPath(t *testing.T) {
|
||||
c := start()
|
||||
bench := NewBench(c.now)
|
||||
bench.SetKinds([]KindInfo{
|
||||
{Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"reaches-away", "choice", "verified-sender", "exact-render"}},
|
||||
{Kind: "desktop", Module: "desk-channel", Node: "anchor", Capabilities: []string{"choice", "exact-render", "private"}},
|
||||
})
|
||||
bench.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
bench.Stand("desktop", Standing{Ready: true, At: c.now()})
|
||||
var mu sync.Mutex
|
||||
put := map[string][]asks.Message{}
|
||||
publish := func(subject string, body any, id string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if m, ok := body.(asks.Message); ok {
|
||||
put[subject] = append(put[subject], m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
h := &Holder{Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish},
|
||||
Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, Bench: bench, Publish: publish,
|
||||
Asks: newMemAsks(), Node: "anchor", Now: c.now, Logf: t.Logf, Zone: func() *time.Location { return time.UTC }}
|
||||
h.init()
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
shown := put[asks.ChannelSubject(asks.Send, "desktop")]
|
||||
if len(shown) != 1 || !strings.Contains(shown[0].Body, "send this code") {
|
||||
t.Fatalf("the code was not shown on the desk: %+v", put)
|
||||
}
|
||||
if !shown[0].Secret {
|
||||
t.Error("the code went to the desk not marked secret: the channel would keep a copy")
|
||||
}
|
||||
}
|
||||
|
||||
// L1: a warrant's message id is new each time: one anyone could foresee lets another publisher on the stream
|
||||
// take it first, and the bus would drop the warrant as a duplicate.
|
||||
func TestAWarrantsMessageIdCannotBeForeseen(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
var ids []string
|
||||
h.Publish = func(subject string, body any, id string) error {
|
||||
if _, ok := body.(asks.Warrant); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
if out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now())); out != "chosen" {
|
||||
t.Fatalf("%s %v", out, err)
|
||||
}
|
||||
_ = h.tell(*store.asks["mesh-delivery.d-1"].Warrant)
|
||||
if len(ids) != 2 || ids[0] == ids[1] {
|
||||
t.Fatalf("ids %v", ids)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if strings.Contains(id, "d-1") || strings.Contains(id, "mesh-delivery") {
|
||||
t.Errorf("the id %q is made of what anyone knows of the ask", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// M2: an ask's message says who asks, as the bus proves it, what each answer does, and what happens if nobody
|
||||
// answers.
|
||||
func TestAnAsksMessageSaysWhoAsksWhatEachAnswerDoesAndWhatExpiryDoes(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
body := tg.sends[0].Body
|
||||
for _, want := range []string{"Asked by mesh-delivery.", "Release: the delivery goes on.", "Stop: the delivery ends.",
|
||||
"Silence for a week: nothing more is said of it for a week.",
|
||||
"If nobody answers by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the message does not say %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// M3: the router's own unlink binds the account it asks about: an approval from that account, after another was
|
||||
// linked in its place, unlinks nothing.
|
||||
func TestAnUnlinkUnlinksOnlyTheAccountItNamed(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
if _, err := h.Unlink("telegram"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlink := ticketFor(tg.sends[len(tg.sends)-1], "Unlink")
|
||||
delete(store.ids, identityKey("telegram", "42"))
|
||||
store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Display: "Al",
|
||||
Linked: c.now().Add(-2 * time.Hour), ApproveFrom: c.now().Add(-time.Hour)}
|
||||
_, _ = h.Chosen("telegram", choiceOf(unlink, "42", c.now()))
|
||||
if _, still := store.ids[identityKey("telegram", "77")]; !still {
|
||||
t.Error("an approval to unlink 42 unlinked 77")
|
||||
}
|
||||
if got := store.asks; len(got) == 0 {
|
||||
t.Fatal("no ask")
|
||||
}
|
||||
for _, rec := range store.asks {
|
||||
if rec.Act != nil && rec.Act.Identity != "42" {
|
||||
t.Errorf("the ask binds %q", rec.Act.Identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// M3: a link is checked again as it is made: an approved relink does not link a second account of a kind.
|
||||
func TestALinkIsNotMadeWhileAnotherAccountIsLinked(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
_, _ = h.Unlink("telegram")
|
||||
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
|
||||
code := codeOf(t, dt.sends[len(dt.sends)-1])
|
||||
_ = h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code})
|
||||
asked := tg.sends[len(tg.sends)-1]
|
||||
// Meanwhile root linked another account (allowed at the machine).
|
||||
store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Linked: c.now(), ApproveFrom: c.now().Add(time.Hour)}
|
||||
_, _ = h.Chosen("telegram", choiceOf(ticketFor(asked, "Allow"), "42", c.now()))
|
||||
if _, linked := store.ids[identityKey("telegram", "666")]; linked {
|
||||
t.Errorf("a second account of a kind was linked: %v", store.ids)
|
||||
}
|
||||
}
|
||||
|
||||
// M3: an account unlinked less than a day ago is not linked again by a code: no code is shown for it.
|
||||
func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
_, _ = h.Unlink("telegram")
|
||||
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
|
||||
shown := len(dt.sends)
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if _, pending := store.links["telegram"]; pending {
|
||||
t.Error("a link was begun for the account unlinked an hour ago")
|
||||
}
|
||||
for _, m := range dt.sends[shown:] {
|
||||
if strings.Contains(m.Body, "send this code") {
|
||||
t.Error("a code was shown for it")
|
||||
}
|
||||
}
|
||||
if len(store.ids) != 0 {
|
||||
t.Error("linked again")
|
||||
}
|
||||
}
|
||||
|
||||
// H4: an account still in its first hour cannot approve yet, so whoever is at the desk may unlink it — asked
|
||||
// there when another account asks to be linked — and past its hour the desk no longer can.
|
||||
func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
|
||||
h, tg, dt, c, store, w := newRouter(t)
|
||||
link(h, store, c, "666", c.now().Add(-10*time.Minute)) // an account the operator did not link
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if reply := tg.sends[len(tg.sends)-1]; !strings.Contains(reply.Body, "unlink it at your desk") {
|
||||
t.Fatalf("the sender was not told how: %q", reply.Body)
|
||||
}
|
||||
asked := dt.sends[len(dt.sends)-1]
|
||||
if labelsOf(asked) != "Unlink it" || !strings.Contains(asked.Body, "number 666") {
|
||||
t.Fatalf("the desk was not asked: %+v", asked)
|
||||
}
|
||||
ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now())
|
||||
ch.Sender.Authenticated = false
|
||||
if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" {
|
||||
t.Fatalf("%s %v", out, err)
|
||||
}
|
||||
if len(store.ids) != 0 {
|
||||
t.Errorf("not unlinked: %v", store.ids)
|
||||
}
|
||||
if len(w.of(SelfAsker)) != 0 {
|
||||
t.Error("the router's own act was told to an asker")
|
||||
}
|
||||
// Past its hour, no question goes to the desk.
|
||||
h2, tg2, dt2, c2, store2, _ := newRouter(t)
|
||||
link(h2, store2, c2, "666", c2.now().Add(-2*time.Hour))
|
||||
h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: asks.Sender{Identity: "42", Authenticated: true}})
|
||||
for _, m := range dt2.sends {
|
||||
if labelsOf(m) == "Unlink it" {
|
||||
t.Error("the desk was asked to unlink an account past its first hour")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "own approval, or as root") {
|
||||
t.Errorf("%q", tg2.sends[len(tg2.sends)-1].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// H4 (C4): while the desk cannot show a code now — its notifications paused — no link is begun and no code is
|
||||
// made or queued; the sender is told why.
|
||||
func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
h.Bench.Stand("desktop", Standing{Ready: false, Why: "notifications are paused on the desk", At: c.now()})
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if _, pending := store.links["telegram"]; pending {
|
||||
t.Error("a link was begun")
|
||||
}
|
||||
if len(dt.sends) != 0 {
|
||||
t.Errorf("something was put to the desk: %+v", dt.sends)
|
||||
}
|
||||
if reply := tg.sends[len(tg.sends)-1].Body; !strings.Contains(reply, "notifications are paused on the desk") {
|
||||
t.Errorf("the sender was not told why: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// L5: links are bounded per account, and kept in the router's state: a stranger's tries do not stop the
|
||||
// operator's link, and a restart forgets no count.
|
||||
func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
eve := asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}
|
||||
for i := 0; i < LinksAnHour; i++ {
|
||||
h.Link("telegram", asks.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve})
|
||||
}
|
||||
shown := len(dt.sends)
|
||||
h.Link("telegram", asks.LinkAsked{ID: "jo", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if len(dt.sends) != shown+1 || !strings.Contains(dt.sends[len(dt.sends)-1].Body, "(number 42)") {
|
||||
t.Fatalf("a stranger's tries stopped the operator's link: %q", tg.sends[len(tg.sends)-1].Body)
|
||||
}
|
||||
// A router started again on the same state still counts the stranger's tries.
|
||||
h2, tg2, dt2, c2, _, _ := newRouter(t)
|
||||
h2.Asks = store
|
||||
c2.pass(c.now().Sub(c2.now()))
|
||||
stand(h2, c2)
|
||||
before := len(dt2.sends)
|
||||
h2.Link("telegram", asks.LinkAsked{ID: "e9", At: c2.now(), Sender: eve})
|
||||
if len(dt2.sends) != before || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "Try again in an hour") {
|
||||
t.Error("a restart forgot the count")
|
||||
}
|
||||
}
|
||||
|
||||
// L7: two answers to one ask arriving at once — on two routers sharing the state — make exactly one warrant.
|
||||
func TestTwoAnswersAtOnceMakeOneWarrant(t *testing.T) {
|
||||
h1, tg, _, c, store, w1 := newRouter(t)
|
||||
link(h1, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
submit(t, h1, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h1, c)
|
||||
release, stop := ticketFor(tg.sends[0], "Release"), ticketFor(tg.sends[0], "Stop")
|
||||
h2, _, _, _, _, w2 := newRouter(t)
|
||||
h2.Asks = store
|
||||
h2.Now = h1.Now
|
||||
if err := h2.loadAsks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
outs := make([]string, 2)
|
||||
for i, pick := range []struct {
|
||||
h *Holder
|
||||
ticket string
|
||||
}{{h1, release}, {h2, stop}} {
|
||||
wg.Add(1)
|
||||
go func(i int, h *Holder, ticket string) {
|
||||
defer wg.Done()
|
||||
outs[i], _ = h.Chosen("telegram", choiceOf(ticket, "42", c.now()))
|
||||
}(i, pick.h, pick.ticket)
|
||||
}
|
||||
wg.Wait()
|
||||
n := len(w1.of("mesh-delivery")) + len(w2.of("mesh-delivery"))
|
||||
if n != 1 {
|
||||
t.Fatalf("%d warrants for one ask (%v)", n, outs)
|
||||
}
|
||||
if (outs[0] == "chosen") == (outs[1] == "chosen") {
|
||||
t.Errorf("not exactly one chosen: %v", outs)
|
||||
}
|
||||
}
|
||||
|
||||
// The confirmation review of 2026-10-09, N1-router: another account's /start offers the desk an unlink of the
|
||||
// operator's account while it is in its first hour; a desk click — which a program on the desk machine can forge
|
||||
// — unlinked it and barred it for a day, again and again, so the operator never approved anything. A desk
|
||||
// unlink in the first hour bars nothing: the operator links again at once with a code from the desk, and the
|
||||
// next link waits for nobody. The bar is for an account removed deliberately later.
|
||||
func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-10*time.Minute)) // the operator's own, linked just now
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "X", Authenticated: true}})
|
||||
asked := dt.sends[len(dt.sends)-1]
|
||||
ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now()) // a click nobody can prove was the operator's
|
||||
ch.Sender.Authenticated = false
|
||||
if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" {
|
||||
t.Fatalf("%s %v", out, err)
|
||||
}
|
||||
if len(store.ids) != 0 {
|
||||
t.Fatalf("not unlinked: %v", store.ids)
|
||||
}
|
||||
if u := h.recentUnlink("telegram"); u != nil {
|
||||
t.Errorf("a desk unlink in the first hour bars relinking: %+v", u)
|
||||
}
|
||||
// The operator sends /start again: a code is shown at once, and the code links without waiting for anybody.
|
||||
c.pass(time.Minute)
|
||||
stand(h, c)
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
code := ""
|
||||
for _, m := range dt.sends {
|
||||
if strings.Contains(m.Body, "number 42") && strings.Contains(m.Body, "send this code") {
|
||||
code = codeIn(t, m.Body)
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
t.Fatalf("no code was shown to relink the operator's account; said %q", tg.sends[len(tg.sends)-1].Body)
|
||||
}
|
||||
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}); !a.Accepted ||
|
||||
!strings.Contains(a.Words, "Linked") {
|
||||
t.Fatalf("the operator's code was not taken at once: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// And break-glass lifts any bar: root's allow-link at the router's machine, with no link waiting, ends a day's bar.
|
||||
func TestBreakGlassLiftsABarOnRelinking(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
_, _ = h.Unlink("telegram")
|
||||
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
|
||||
if h.recentUnlink("telegram") == nil {
|
||||
t.Fatal("the control: a deliberate unlink bars relinking for a day")
|
||||
}
|
||||
h.StateDir = t.TempDir()
|
||||
was := fileOwner
|
||||
t.Cleanup(func() { fileOwner = was })
|
||||
fileOwner = func(os.FileInfo) uint32 { return 0 }
|
||||
_ = os.WriteFile(filepath.Join(h.StateDir, "allow-link.telegram"), nil, 0o600)
|
||||
h.Tick()
|
||||
if u := h.recentUnlink("telegram"); u != nil {
|
||||
t.Errorf("root's allow-link left the bar: %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
// codeIn is the six digits a link's notice shows, "123 456".
|
||||
func codeIn(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
m := regexp.MustCompile(`(\d{3}) (\d{3})\.`).FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
t.Fatalf("no code in %q", body)
|
||||
}
|
||||
return m[1] + m[2]
|
||||
}
|
||||
|
||||
// The confirmation review of 2026-10-09 (low): the router asked the controller's root-free word — up to ten
|
||||
// seconds — while holding its work, so every ask, answer and link waited behind one slow controller. It now
|
||||
// asks before it takes the work, and judges with the word it was given then.
|
||||
func TestTheRootFreeWordIsNotAskedWhileTheWorkIsHeld(t *testing.T) {
|
||||
h, tg, _, c, store, w := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
release := ticketFor(tg.sends[0], "Release")
|
||||
asking, slow := make(chan struct{}), make(chan struct{})
|
||||
var once sync.Once
|
||||
free := h.RootFree
|
||||
h.RootFree = func(machines []string) (map[string]RootVerdict, error) {
|
||||
once.Do(func() { close(asking); <-slow })
|
||||
return free(machines)
|
||||
}
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
out, _ := h.Chosen("telegram", choiceOf(release, "42", c.now()))
|
||||
done <- out
|
||||
}()
|
||||
<-asking
|
||||
linked := make(chan struct{})
|
||||
go func() {
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "77", Authenticated: true}})
|
||||
close(linked)
|
||||
}()
|
||||
select {
|
||||
case <-linked:
|
||||
case <-time.After(2 * time.Second):
|
||||
close(slow)
|
||||
t.Fatal("a link waited for the controller's root-free word: the work was held while it was asked")
|
||||
}
|
||||
close(slow)
|
||||
if out := <-done; out != "chosen" || len(w.of("mesh-delivery")) != 1 {
|
||||
t.Fatalf("the answer after the word: %s, %v", out, w.of("mesh-delivery"))
|
||||
}
|
||||
}
|
||||
|
||||
// The confirmation review of 2026-10-09 (low, on M2): an answer whose line — what it does — the content rule
|
||||
// withholds is not offered either; its button went out alone, and a tap would choose what was not shown.
|
||||
func TestAnAnswerWhoseEffectIsWithheldIsNotOffered(t *testing.T) {
|
||||
h, _, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
key := askKey("mesh-delivery", "d-1")
|
||||
h.mu.Lock()
|
||||
e := h.asks[key]
|
||||
e.rec.Ask.Options[0].Does = "the delivery reads /etc/shadow"
|
||||
h.asks[key] = e
|
||||
r := h.open["ask."+key]
|
||||
h.mu.Unlock()
|
||||
m := h.sayOn(r, "asked", "telegram")
|
||||
for _, a := range m.Actions {
|
||||
if a.Label == "Release" {
|
||||
t.Errorf("Release was offered with its effect withheld: %+v", m.Actions)
|
||||
}
|
||||
}
|
||||
if strings.Contains(m.Body, "/etc/shadow") {
|
||||
t.Errorf("the withheld words were said: %q", m.Body)
|
||||
}
|
||||
if len(m.Actions) == 0 {
|
||||
t.Error("the answers whose effect was said were not offered either")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package main
|
||||
|
||||
// The channels, as the router sees them (novox/hq ADR 0259): kinds of the `channel` bench, each held by a
|
||||
// module of its own. The router keeps no channel's client and knows no service. What it knows of a kind
|
||||
// comes from two places, never from one alone:
|
||||
//
|
||||
// - **the controller**, in this module's membership: which kinds exist, which module holds each, and the
|
||||
// capabilities its claim declares (`channel-capabilities/1`). An answer is judged by these.
|
||||
// - **the channel**, on the `intake` bench under its own kind: whether it can send now (`standing`), and
|
||||
// a message it could not deliver (`failed`).
|
||||
//
|
||||
// A message is sent by putting it on the kind's work queue (`mesh.seat.channel.accept.<verb>.<kind>`) under
|
||||
// a handle the router makes; the channel keeps which of its own messages that handle is, so an edit names
|
||||
// the handle. A queued message survives the channel being away.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// StandingFresh is how long a channel's word on whether it can send is believed: a channel says it at
|
||||
// least every five minutes, so a word older than this is a channel that stopped saying.
|
||||
const StandingFresh = 15 * time.Minute
|
||||
|
||||
// KindInfo is one kind of the channel bench as the controller issued it.
|
||||
type KindInfo struct {
|
||||
Kind string
|
||||
Module string
|
||||
Node string
|
||||
Capabilities []string
|
||||
}
|
||||
|
||||
// Standing is what a channel last said of itself.
|
||||
type Standing struct {
|
||||
Ready bool `json:"ready"`
|
||||
Why string `json:"why,omitempty"`
|
||||
EditsSilently bool `json:"edits-silently,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// Bench is every kind the router may send to, and what each said.
|
||||
type Bench struct {
|
||||
Now func() time.Time
|
||||
// To is the account linked as the operator on a kind, where it verifies its sender: where its messages go.
|
||||
To func(kind string) string
|
||||
|
||||
mu sync.Mutex
|
||||
kinds map[string]KindInfo
|
||||
standing map[string]Standing
|
||||
known bool
|
||||
knownAt time.Time
|
||||
}
|
||||
|
||||
// KindsFresh is how long the controller's word on the kinds is believed for a proof: the router reads it every
|
||||
// half minute, so an older word is one it could not read again. A stale word keeps the kinds for sending, and
|
||||
// promises no verified sender (the review of 2026-10-09: a capability the controller withdrew must not stay).
|
||||
const KindsFresh = 2 * time.Minute
|
||||
|
||||
// NewBench is a bench that knows no kind yet.
|
||||
func NewBench(now func() time.Time) *Bench {
|
||||
return &Bench{Now: now, kinds: map[string]KindInfo{}, standing: map[string]Standing{}}
|
||||
}
|
||||
|
||||
// SetKinds takes the kinds the controller issued.
|
||||
func (b *Bench) SetKinds(kinds []KindInfo) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.kinds = map[string]KindInfo{}
|
||||
for _, k := range kinds {
|
||||
b.kinds[k.Kind] = k
|
||||
}
|
||||
b.known = true
|
||||
b.knownAt = b.Now()
|
||||
}
|
||||
|
||||
// Stand takes what a kind said of itself.
|
||||
func (b *Bench) Stand(kind string, s Standing) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.standing[kind] = s
|
||||
}
|
||||
|
||||
// Kind is one kind, as the controller issued it.
|
||||
func (b *Bench) Kind(kind string) (KindInfo, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
k, ok := b.kinds[kind]
|
||||
return k, ok
|
||||
}
|
||||
|
||||
// Kinds is every kind, by name.
|
||||
func (b *Bench) Kinds() []KindInfo {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
out := make([]KindInfo, 0, len(b.kinds))
|
||||
for _, k := range b.kinds {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Kind < out[j].Kind })
|
||||
return out
|
||||
}
|
||||
|
||||
// Promises says whether a kind's claim declares a capability: the controller's record, never the channel's.
|
||||
func (b *Bench) Promises(kind, capability string) bool {
|
||||
k, ok := b.Kind(kind)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if capability == "verified-sender" {
|
||||
b.mu.Lock()
|
||||
at := b.knownAt
|
||||
b.mu.Unlock()
|
||||
if age := b.Now().Sub(at); age > KindsFresh || age < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, c := range k.Capabilities {
|
||||
if c == capability {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Ready says whether a kind can send now, in words when it cannot.
|
||||
func (b *Bench) Ready(kind string) error {
|
||||
b.mu.Lock()
|
||||
_, held := b.kinds[kind]
|
||||
s, said := b.standing[kind]
|
||||
known := b.known
|
||||
b.mu.Unlock()
|
||||
switch {
|
||||
case !held && !known:
|
||||
return errors.New("the mesh has not yet said which channels exist")
|
||||
case !held:
|
||||
return fmt.Errorf("no module holds the channel %s", kind)
|
||||
case !said:
|
||||
return fmt.Errorf("%s has not said whether it can send", kind)
|
||||
case b.Now().Sub(s.At) > StandingFresh:
|
||||
return fmt.Errorf("%s has said nothing since %s", kind, s.At.UTC().Format(time.RFC3339))
|
||||
case !s.Ready:
|
||||
if s.Why == "" {
|
||||
return fmt.Errorf("%s says it cannot send", kind)
|
||||
}
|
||||
return errors.New(s.Why)
|
||||
case b.Promises(kind, "verified-sender") && b.To != nil && b.To(kind) == "":
|
||||
// A channel proving its sender says only to the account linked as the operator: none linked, it has
|
||||
// nobody to say anything to (the correctness review of 2026-10-08).
|
||||
return fmt.Errorf("no %s account is linked as the operator: send /start to the bot", Display(kind))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bench) editsSilently(kind string) bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.standing[kind].EditsSilently
|
||||
}
|
||||
|
||||
// Roles: the away channel reaches the operator away from the machines; the desk is at them.
|
||||
const (
|
||||
RoleAway = "away"
|
||||
RoleDesk = "desk"
|
||||
)
|
||||
|
||||
// roleOf is a kind's role, from what its claim declares.
|
||||
func (b *Bench) roleOf(kind string) string {
|
||||
if b.Promises(kind, "reaches-away") {
|
||||
return RoleAway
|
||||
}
|
||||
return RoleDesk
|
||||
}
|
||||
|
||||
// Display is a kind as the operator reads it: "Telegram", "the desk".
|
||||
func Display(kind string) string {
|
||||
if kind == "desktop" {
|
||||
return "the desk"
|
||||
}
|
||||
if kind == "" {
|
||||
return kind
|
||||
}
|
||||
return strings.ToUpper(kind[:1]) + kind[1:]
|
||||
}
|
||||
|
||||
// SeatChannel is one role's channel: the first kind of that role that can send, else the first of it.
|
||||
type SeatChannel struct {
|
||||
Bench *Bench
|
||||
Role string
|
||||
Publish func(subject string, body any, id string) error
|
||||
}
|
||||
|
||||
func (c *SeatChannel) kind() string {
|
||||
var first string
|
||||
for _, k := range c.Bench.Kinds() {
|
||||
if c.Bench.roleOf(k.Kind) != c.Role {
|
||||
continue
|
||||
}
|
||||
if first == "" {
|
||||
first = k.Kind
|
||||
}
|
||||
if c.Bench.Ready(k.Kind) == nil {
|
||||
return k.Kind
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// Name is the kind it sends to now, or its role when no kind holds it.
|
||||
func (c *SeatChannel) Name() string {
|
||||
if k := c.kind(); k != "" {
|
||||
return k
|
||||
}
|
||||
return c.Role
|
||||
}
|
||||
|
||||
func (c *SeatChannel) Ready() error {
|
||||
k := c.kind()
|
||||
if k == "" {
|
||||
if c.Role == RoleAway {
|
||||
return errors.New("no channel reaching the operator away from the machines is held: a module claims a " +
|
||||
"kind of the channel bench that promises reaches-away (novox/hq ADR 0259)")
|
||||
}
|
||||
return errors.New("no channel at the operator's machines is held")
|
||||
}
|
||||
return c.Bench.Ready(k)
|
||||
}
|
||||
|
||||
func (c *SeatChannel) CanEdit() bool { return c.Bench.Promises(c.kind(), "edit") }
|
||||
func (c *SeatChannel) SilentEdit() bool { return c.Bench.editsSilently(c.kind()) }
|
||||
|
||||
// Send puts a message on the kind's queue and answers the handle it is known by.
|
||||
func (c *SeatChannel) Send(m Message) (string, error) {
|
||||
handle := newHandle()
|
||||
return handle, c.put(handle, m, false)
|
||||
}
|
||||
|
||||
// Edit replaces the message a handle names.
|
||||
func (c *SeatChannel) Edit(handle string, m Message) error {
|
||||
if handle == "" {
|
||||
return errors.New("no message on record to replace")
|
||||
}
|
||||
return c.put(handle, m, true)
|
||||
}
|
||||
|
||||
func (c *SeatChannel) put(handle string, m Message, edit bool) error {
|
||||
kind := c.kind()
|
||||
if kind == "" {
|
||||
return c.Ready()
|
||||
}
|
||||
return c.putOn(kind, handle, m, edit)
|
||||
}
|
||||
|
||||
func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
|
||||
if c.Publish == nil {
|
||||
return errors.New("this router cannot reach the bus")
|
||||
}
|
||||
verb := asks.Send
|
||||
switch {
|
||||
case edit:
|
||||
verb = asks.Edit
|
||||
case len(m.Actions) > 0:
|
||||
verb = asks.Show
|
||||
}
|
||||
// Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it
|
||||
// dropped here, where the desk's own role sends).
|
||||
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
|
||||
Secret: m.Secret}
|
||||
if c.Bench.To != nil {
|
||||
out.To = c.Bench.To(kind)
|
||||
}
|
||||
if m.To != "" {
|
||||
out.To = m.To
|
||||
}
|
||||
for _, a := range m.Actions {
|
||||
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
|
||||
}
|
||||
return c.Publish(asks.ChannelSubject(verb, kind), out, handle+"."+fmt.Sprint(time.Now().UnixNano()))
|
||||
}
|
||||
|
||||
func newHandle() string {
|
||||
var b [9]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return "m" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// Asker calls a tool through the mesh, as stdio.Ask does: how the open conditions are read.
|
||||
type Asker func(key string, body any) (json.RawMessage, error)
|
||||
|
||||
// KindChannel is one kind as a channel: for a test, and for the status of each.
|
||||
type KindChannel struct {
|
||||
Bench *Bench
|
||||
Kind string
|
||||
Publish func(subject string, body any, id string) error
|
||||
}
|
||||
|
||||
func (c *KindChannel) Name() string { return c.Kind }
|
||||
func (c *KindChannel) Ready() error { return c.Bench.Ready(c.Kind) }
|
||||
func (c *KindChannel) CanEdit() bool { return c.Bench.Promises(c.Kind, "edit") }
|
||||
func (c *KindChannel) SilentEdit() bool { return c.Bench.editsSilently(c.Kind) }
|
||||
func (c *KindChannel) Send(m Message) (string, error) {
|
||||
s := &SeatChannel{Bench: c.Bench, Publish: c.Publish}
|
||||
handle := newHandle()
|
||||
return handle, s.putOn(c.Kind, handle, m, false)
|
||||
}
|
||||
func (c *KindChannel) Edit(handle string, m Message) error {
|
||||
s := &SeatChannel{Bench: c.Bench, Publish: c.Publish}
|
||||
return s.putOn(c.Kind, handle, m, true)
|
||||
}
|
||||
|
||||
// kindChannels is every kind the controller says exists, as a channel; the two roles when it says none yet.
|
||||
func (h *Holder) kindChannels() []Channel {
|
||||
if h.Bench == nil || len(h.Bench.Kinds()) == 0 {
|
||||
return h.channels()
|
||||
}
|
||||
var out []Channel
|
||||
for _, k := range h.Bench.Kinds() {
|
||||
out = append(out, &KindChannel{Bench: h.Bench, Kind: k.Kind, Publish: h.Publish})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package main
|
||||
|
||||
// The router's own asks (novox/hq ADR 0259 §7, as revised after the security review of 2026-10-08): who the
|
||||
// operator is on a channel changes only with the word of the account it is now.
|
||||
//
|
||||
// - **Unlinking is an approve ask, put to the account that is linked.** Whoever could unlink the
|
||||
// operator's account without it could link their own in its place. The notice of the unlink reaches that
|
||||
// account first, by name, and then every channel.
|
||||
// - **A link within a day of an unlink waits for the replaced account's approval**, asked of it by name.
|
||||
// - **When that account is lost with its phone**, the operator, as root at the router's machine, leaves a
|
||||
// file in the router's directory — `unlink.<kind>` or `allow-link.<kind>`, owned by root — and the router
|
||||
// does it, announced as done at the machine. A file of any other owner is refused and said: an agent
|
||||
// without root cannot make one.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// SelfAsker is the name the router asks its own questions under.
|
||||
const SelfAsker = "messenger"
|
||||
|
||||
// RelinkWaits is how long after an unlink a new link waits for the replaced account's approval.
|
||||
const RelinkWaits = 24 * time.Hour
|
||||
|
||||
// SelfAct is what the router does on its own ask's warrant. It names the account it concerns: an option
|
||||
// binds that account, so a warrant for unlinking one account never unlinks another linked in its place
|
||||
// (the review of 2026-10-09).
|
||||
type SelfAct struct {
|
||||
Do string `json:"do"` // "unlink" or "link"
|
||||
Kind string `json:"kind"`
|
||||
Identity string `json:"identity"` // the account unlinked, or the account a link makes the operator's
|
||||
Link *Identity `json:"link,omitempty"` // the account a link makes the operator's
|
||||
}
|
||||
|
||||
// act is what an option of the router's own ask binds: its named fields (asks.ActDigest).
|
||||
func (a SelfAct) act() asks.Act {
|
||||
return asks.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity}
|
||||
}
|
||||
|
||||
// Unlinked is the last account unlinked on a kind, kept a day so a relink is asked of it.
|
||||
type Unlinked struct {
|
||||
Identity Identity `json:"identity"`
|
||||
At time.Time `json:"at"`
|
||||
// Early is an unlink at the desk in the account's first hour: it bars nothing (the confirmation review of
|
||||
// 2026-10-09, N1-router). A desk click is anyone's at the desk machine, and a bar it set — the account not
|
||||
// linked again for a day, the next link waiting for it — let a forged click lock the operator out, again
|
||||
// and again. The bar is for an account removed deliberately: by its own approval, or by root.
|
||||
Early bool `json:"early,omitempty"`
|
||||
}
|
||||
|
||||
// askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask.
|
||||
func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error {
|
||||
// Its options bind the act, as any asker's do (ADR 0259 §6): the warrant then performs this act alone.
|
||||
binds, err := asks.ActDigest(act.act())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range a.Options {
|
||||
a.Options[i].Binds = binds
|
||||
}
|
||||
key := askKey(SelfAsker, a.ID)
|
||||
now := h.Now()
|
||||
rec := AskRecord{Asker: SelfAsker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{},
|
||||
Only: &only, Act: &act, Record: "ask." + key}
|
||||
for _, o := range a.Options {
|
||||
rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: only.Kind}
|
||||
}
|
||||
if h.Asks != nil {
|
||||
rev, err := h.Asks.CreateAsk(key, rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.keepAsk(key, rec, rev)
|
||||
} else {
|
||||
h.keepAsk(key, rec, 0)
|
||||
}
|
||||
r := Record{Key: rec.Record, Kind: "ask", Subject: SelfAsker, Severity: Urgent, Summary: a.Headline,
|
||||
Headline: a.Headline, Explanation: a.Explanation, Origin: "ask", More: "operator-channel.asks",
|
||||
Raised: now, Sent: map[string]string{}, Seen: now, Count: 1, Ask: key}
|
||||
h.mu.Lock()
|
||||
h.open[r.Key] = &r
|
||||
h.mu.Unlock()
|
||||
m := h.words(&r, "raised")
|
||||
m.Actions = h.offers(&rec, only.Kind)
|
||||
m.To = only.Identity
|
||||
handle, err := h.showOnHandle(only.Kind, m)
|
||||
if err == nil {
|
||||
h.mu.Lock()
|
||||
r.Sent[only.Kind] = handle
|
||||
r.FirstSent = now
|
||||
h.mu.Unlock()
|
||||
}
|
||||
h.persist(&r)
|
||||
return err
|
||||
}
|
||||
|
||||
// selfAct does what the router's own ask decided.
|
||||
func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
|
||||
if rec.Act == nil || w.Outcome != asks.OutcomeChosen {
|
||||
return
|
||||
}
|
||||
o, err := w.For(SelfAsker, rec.Ask)
|
||||
if err == nil {
|
||||
err = o.Performs(rec.Act.act())
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[messenger] REFUSED a warrant for its own ask %s: %v", rec.Ask.ID, err)
|
||||
return
|
||||
}
|
||||
switch rec.Act.Do {
|
||||
case "unlink":
|
||||
if o.Level == asks.Acknowledge {
|
||||
// Asked at the desk: only an account still in its first hour, which cannot approve yet, is unlinked
|
||||
// this way (ADR 0259 §7, the review of 2026-10-09). Past its hour it decides for itself.
|
||||
id := h.identity(rec.Act.Kind, rec.Act.Identity)
|
||||
if id == nil || !h.Now().Before(id.ApproveFrom) {
|
||||
h.Logf("[messenger] an unlink chosen at the desk was not done: the account's first hour has passed")
|
||||
h.announce(Message{Title: "An unlink chosen at the desk was not done",
|
||||
Body: "The account's first hour had passed, so only the account itself, or root at the router's " +
|
||||
"machine, can unlink it now.", Urgent: true})
|
||||
return
|
||||
}
|
||||
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "at the desk, within the hour after it was linked", true)
|
||||
return
|
||||
}
|
||||
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "approved by the account itself", false)
|
||||
case "link":
|
||||
if rec.Act.Link != nil && rec.Act.Link.Identity == rec.Act.Identity {
|
||||
h.linkNow(*rec.Act.Link, "approved by the account it replaces")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unlink asks the account linked on a kind whether it is to be unlinked: an approve ask put to it alone.
|
||||
func (h *Holder) Unlink(kind string) (map[string]any, error) {
|
||||
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
ids := h.identitiesOf(kind)
|
||||
if len(ids) == 0 {
|
||||
return map[string]any{"kind": kind, "linked": false, "asked": false}, nil
|
||||
}
|
||||
id := ids[0]
|
||||
now := h.Now()
|
||||
a := asks.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?",
|
||||
Explanation: fmt.Sprintf("Unlinking this %s account (%s, number %s) was asked. If you choose Unlink, answers "+
|
||||
"from here stop counting as yours until an account is linked again.", Display(kind), id.Display, id.Identity),
|
||||
Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the account stays linked",
|
||||
Options: []asks.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: asks.Approve}}}
|
||||
if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind, Identity: id.Identity}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.announceExcept(kind, Message{Title: "An unlink of your " + Display(kind) + " account was asked",
|
||||
Body: fmt.Sprintf("It waits for the account itself (%s, number %s) to approve it within the hour. If you "+
|
||||
"cannot answer there, you unlink it as root at the router's machine.", id.Display, id.Identity), Urgent: true})
|
||||
return map[string]any{"kind": kind, "asked": true, "of": id.Display}, nil
|
||||
}
|
||||
|
||||
// unlinkNow unlinks one account of a kind: told first, by name, then every channel. It unlinks the account it
|
||||
// is named, and nothing when another is linked now.
|
||||
func (h *Holder) unlinkNow(kind, identity, how string, early bool) {
|
||||
if h.Asks == nil {
|
||||
return
|
||||
}
|
||||
id := h.identity(kind, identity)
|
||||
if id == nil {
|
||||
h.Logf("[messenger] the unlink of %s on %s was not done: that account is not linked", identity, kind)
|
||||
return
|
||||
}
|
||||
_ = h.showOn(kind, Message{Title: "This account is no longer linked as you",
|
||||
Body: "Unlinked, " + how + ". Answers from here no longer count as yours.", Urgent: true, To: id.Identity})
|
||||
if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil {
|
||||
h.Logf("[messenger] the unlink of %s on %s could not be recorded: %v", id.Display, kind, err)
|
||||
return
|
||||
}
|
||||
h.unlinked(kind, Unlinked{Identity: *id, At: h.Now(), Early: early})
|
||||
after := "A new link within a day waits for that account's approval, and that account itself can be linked " +
|
||||
"again only after a day."
|
||||
if early {
|
||||
after = "Any account, this one too, can be linked again at once with a code shown on your desk."
|
||||
}
|
||||
h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more",
|
||||
Body: fmt.Sprintf("Unlinked: %s (number %s), %s. %s", id.Display, id.Identity, how, after), Urgent: true})
|
||||
h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: id.Display + ", " + how})
|
||||
}
|
||||
|
||||
// linkNow links an account as the operator's: acknowledges at once, approves after the hour.
|
||||
func (h *Holder) linkNow(id Identity, how string) {
|
||||
if h.Asks == nil {
|
||||
return
|
||||
}
|
||||
// One account per kind, checked again as it is linked: a link approved, or allowed by root, while
|
||||
// another account was linked meanwhile is not made (the review of 2026-10-09).
|
||||
for _, other := range h.identitiesOf(id.Kind) {
|
||||
if other.Identity != id.Identity {
|
||||
h.Logf("[messenger] the link of %s on %s was not made: %s is linked there", id.Display, id.Kind, other.Display)
|
||||
h.announce(Message{Title: "A " + Display(id.Kind) + " link was not made",
|
||||
Body: fmt.Sprintf("%s (number %s) was not linked, %s: the account %s (number %s) is linked there now. "+
|
||||
"One account is linked per channel.", id.Display, id.Identity, how, other.Display, other.Identity),
|
||||
Urgent: true})
|
||||
return
|
||||
}
|
||||
return // already linked
|
||||
}
|
||||
now := h.Now()
|
||||
id.Linked, id.ApproveFrom = now, now.Add(LinkedApproveAfter)
|
||||
if err := h.Asks.PutIdentity(identityKey(id.Kind, id.Identity), id); err != nil {
|
||||
h.Logf("[messenger] the link of %s on %s could not be recorded: %v", id.Display, id.Kind, err)
|
||||
return
|
||||
}
|
||||
_ = h.Asks.DeleteLink(id.Kind)
|
||||
from := id.ApproveFrom.In(h.zone()).Format("15:04")
|
||||
h.announce(Message{Title: "A " + Display(id.Kind) + " account is now linked as you",
|
||||
Body: fmt.Sprintf("The %s account %s (number %s) is linked, %s. It can answer acknowledgements now, and "+
|
||||
"approve from %s. If this was not you, unlink it before then.", Display(id.Kind), id.Display, id.Identity, how, from),
|
||||
Urgent: true})
|
||||
h.record(Sent{At: now, Channel: id.Kind, Key: "(link)", What: "linked " + id.Display, Outcome: how})
|
||||
}
|
||||
|
||||
// breakGlass does what root asked at the router's machine: a file `unlink.<kind>` or `allow-link.<kind>` in
|
||||
// the router's directory, owned by root. Called every minute from Tick, which holds the work: it takes no
|
||||
// lock of its own (the review of 2026-10-09 found it took the work again, and so hung the router's minute
|
||||
// the first time root used it). Loud: what root did, or why nothing was done, is said on every channel, and
|
||||
// every open question of the router's own ends, since root decided in its place.
|
||||
func (h *Holder) breakGlass() {
|
||||
if h.StateDir == "" {
|
||||
return
|
||||
}
|
||||
for _, pattern := range []string{"unlink.*", "allow-link.*"} {
|
||||
files, _ := filepath.Glob(filepath.Join(h.StateDir, pattern))
|
||||
for _, f := range files {
|
||||
name := filepath.Base(f)
|
||||
what, kind, _ := strings.Cut(name, ".")
|
||||
info, err := os.Lstat(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
owner := fileOwner(info)
|
||||
_ = os.Remove(f)
|
||||
if !info.Mode().IsRegular() || owner != 0 {
|
||||
h.Logf("[messenger] %s was refused: it is not a file root made", name)
|
||||
h.announce(Message{Title: "A request to change who is you was refused",
|
||||
Body: "A file asking it was left at the router's machine by someone other than root. Nothing changed.",
|
||||
Urgent: true})
|
||||
continue
|
||||
}
|
||||
where := "at the router's machine"
|
||||
if h.Node != "" {
|
||||
where = "at " + h.Node
|
||||
}
|
||||
done := ""
|
||||
switch what {
|
||||
case "unlink":
|
||||
if ids := h.identitiesOf(kind); len(ids) > 0 {
|
||||
h.unlinkNow(kind, ids[0].Identity, where+", as root", false)
|
||||
done = fmt.Sprintf("the %s account %s (number %s) was unlinked", Display(kind), ids[0].Display, ids[0].Identity)
|
||||
} else {
|
||||
done = "nothing was unlinked: no " + Display(kind) + " account is linked"
|
||||
}
|
||||
case "allow-link":
|
||||
if p, err := h.Asks.GetLink(kind); err == nil && p != nil && p.Confirmed {
|
||||
h.linkNow(Identity{Kind: kind, Identity: p.Identity, Display: p.Display}, "allowed "+where+", as root")
|
||||
done = fmt.Sprintf("the waiting link of %s (number %s) was allowed", p.Display, p.Identity)
|
||||
} else {
|
||||
done = "nothing was linked: no link with its code given waits on " + Display(kind)
|
||||
}
|
||||
// And whatever bar an unlink set is lifted: root decided who may be linked.
|
||||
if h.recentUnlink(kind) != nil {
|
||||
h.liftBar(kind)
|
||||
done += "; the bar on linking an account again after an unlink was lifted"
|
||||
}
|
||||
}
|
||||
ended := h.endSelfAsks("root decided " + where)
|
||||
body := "Root " + where + " asked it: " + done + "."
|
||||
if ended > 0 {
|
||||
body += fmt.Sprintf(" %d open question(s) of the router's own were ended.", ended)
|
||||
}
|
||||
h.Logf("[messenger] break-glass %s: %s", name, done)
|
||||
h.announce(Message{Title: "Root changed who is you", Body: body, Urgent: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endSelfAsks ends every open ask of the router's own: root decided in their place.
|
||||
func (h *Holder) endSelfAsks(why string) int {
|
||||
h.mu.Lock()
|
||||
var keys []string
|
||||
for k, e := range h.asks {
|
||||
if e.rec.Asker == SelfAsker && e.rec.State == StateOpen {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
sort.Strings(keys)
|
||||
n := 0
|
||||
for _, k := range keys {
|
||||
if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, ""); err == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// fileOwner is the uid owning a file; a variable so a test, which is not root, can stand for root.
|
||||
var fileOwner = func(info os.FileInfo) uint32 {
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
return st.Uid
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// unlinked keeps the last unlink of a kind, a day.
|
||||
func (h *Holder) unlinked(kind string, u Unlinked) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.lastUnlink == nil {
|
||||
h.lastUnlink = map[string]Unlinked{}
|
||||
}
|
||||
h.lastUnlink[kind] = u
|
||||
if h.Asks != nil {
|
||||
_ = h.Asks.PutUnlinked(kind, u)
|
||||
}
|
||||
}
|
||||
|
||||
// liftBar forgets the last unlink of a kind: root's allow-link at the router's machine.
|
||||
func (h *Holder) liftBar(kind string) {
|
||||
h.mu.Lock()
|
||||
delete(h.lastUnlink, kind)
|
||||
h.mu.Unlock()
|
||||
if h.Asks != nil {
|
||||
_ = h.Asks.PutUnlinked(kind, Unlinked{})
|
||||
}
|
||||
}
|
||||
|
||||
// recentUnlink is the account unlinked deliberately on a kind less than a day ago, if any: an early desk
|
||||
// unlink bars nothing.
|
||||
func (h *Holder) recentUnlink(kind string) *Unlinked {
|
||||
h.mu.Lock()
|
||||
u, ok := h.lastUnlink[kind]
|
||||
h.mu.Unlock()
|
||||
if !ok && h.Asks != nil {
|
||||
if got, err := h.Asks.GetUnlinked(kind); err == nil && got != nil {
|
||||
u, ok = *got, true
|
||||
}
|
||||
}
|
||||
if !ok || u.Early || u.At.IsZero() || h.Now().Sub(u.At) >= RelinkWaits {
|
||||
return nil
|
||||
}
|
||||
return &u
|
||||
}
|
||||
|
||||
// announceExcept says a message on every kind that can send but one.
|
||||
func (h *Holder) announceExcept(kind string, m Message) {
|
||||
if h.Bench == nil {
|
||||
return
|
||||
}
|
||||
for _, k := range h.Bench.Kinds() {
|
||||
if k.Kind != kind && h.Bench.Ready(k.Kind) == nil {
|
||||
_ = h.showOn(k.Kind, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ type Status struct {
|
||||
func (h *Holder) Status(listening string) Status {
|
||||
now := h.Now()
|
||||
var st Status
|
||||
for _, ch := range h.channels() {
|
||||
for _, ch := range h.kindChannels() {
|
||||
cs := ChannelStatus{Name: ch.Name(), Cap: CapPerHour, SentLastHour: h.sentLastHour(ch.Name())}
|
||||
if err := ch.Ready(); err != nil {
|
||||
cs.NotReady = err.Error()
|
||||
@@ -103,13 +103,17 @@ func (h *Holder) Status(listening string) Status {
|
||||
"history is never news: what is open is read from the controller on start, every 10 min and when an old event arrives; an event older than that reading is not acted on, one older than 10 min by its own time is state, never a message",
|
||||
"a clearing of something never said says nothing; an urgent condition open and never said is said, all of them in one message",
|
||||
"nothing is sent in the first minute after start but that one message",
|
||||
"urgent: Telegram and the desktop; warning: the desktop where a session answers, otherwise Telegram",
|
||||
"urgent: every channel kind that can send; warning: the desk where it can send, otherwise the channel that reaches the operator away",
|
||||
"the channels are kinds of the channel bench, each a module of its own; which exist and what each promises is the controller's word, in this module's membership; whether each can send is its own (novox/hq ADR 0259)",
|
||||
"an ask is held here and offered on each kind whose capabilities carry its answers; an answer is judged here and closes the ask with a warrant its asker acts on; nothing is performed here",
|
||||
"an account linked as the operator acknowledges at once and approves an hour after it was linked; every link and unlink is said on every channel",
|
||||
"the desk proves nothing about who clicked: it answers acknowledgements only",
|
||||
"bursts are one message: what arrives within 30 s goes out together, a digest when it is several; urgent goes at once when the channel was quiet for 30 s",
|
||||
"the desktop is gentle: warnings at most one message every 15 min",
|
||||
"the desk is gentle: warnings at most one message every 15 min",
|
||||
"deduplicated by the condition's key; reminded once after 1 h (urgent) or 12 h (warning); cleared by editing the first message",
|
||||
"at most 20 messages an hour per channel, a last line of defence: reached, it is said once and the rest held for one digest",
|
||||
"refused: anything carrying an address, a path or a secret; channel-refused is sent with the words withheld",
|
||||
"silenced conditions send nothing; silence is `conditions silence` through the mesh",
|
||||
"silenced conditions send nothing; a silence is the controller's, asked of the operator or set through the mesh",
|
||||
}
|
||||
anyCan := false
|
||||
for _, c := range st.Channels {
|
||||
@@ -218,7 +222,7 @@ func (h *Holder) Test(channel, text string) map[string]string {
|
||||
return out
|
||||
}
|
||||
m := Message{Title: "Test message: " + text, Body: "Nothing is wrong; this was asked for."}
|
||||
for _, ch := range h.channels() {
|
||||
for _, ch := range h.kindChannels() {
|
||||
if channel != "" && channel != "both" && channel != ch.Name() {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
package main
|
||||
|
||||
// The Telegram channel: a bot to the operator's chat (novox/hq to-be 45 §5, research 028/02). Sending
|
||||
// needs only outbound HTTPS. The bot token is this module's own secret, accepted from the operator; the
|
||||
// chat id is a setting. Neither is ever said: an error from the HTTP client carries the URL, and the
|
||||
// URL carries the token, so every error is rebuilt here from its kind before it leaves this file.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelegramAPI is where the bot API answers; a test points it elsewhere.
|
||||
var TelegramAPI = "https://api.telegram.org"
|
||||
|
||||
var (
|
||||
tokenShape = regexp.MustCompile(`^\d{5,}:[A-Za-z0-9_-]{30,}$`)
|
||||
chatIDShape = regexp.MustCompile(`^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{4,})$`)
|
||||
)
|
||||
|
||||
// TelegramConfig is where the token is and what the chat is; read each time it is used, so a secret
|
||||
// accepted or a setting changed takes effect at the next message without a restart.
|
||||
type TelegramConfig struct {
|
||||
TokenFile string
|
||||
ChatID func() string
|
||||
}
|
||||
|
||||
// Telegram sends to one chat.
|
||||
type Telegram struct {
|
||||
Config TelegramConfig
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewTelegram(cfg TelegramConfig) *Telegram {
|
||||
return &Telegram{Config: cfg, Client: &http.Client{Timeout: 20 * time.Second}}
|
||||
}
|
||||
|
||||
func (t *Telegram) Name() string { return "telegram" }
|
||||
|
||||
// Ready says whether the channel can send, in words.
|
||||
func (t *Telegram) Ready() error {
|
||||
_, _, err := t.ready()
|
||||
return err
|
||||
}
|
||||
|
||||
// ready says whether the channel can send, and when not, what the operator must give. The words name
|
||||
// the setting and the secret, never a value.
|
||||
func (t *Telegram) ready() (string, string, error) {
|
||||
token, err := t.token()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
chat := strings.TrimSpace(t.Config.ChatID())
|
||||
if chat == "" {
|
||||
return "", "", errors.New("no chat to send to: the setting telegram-chat-id is not given " +
|
||||
"(`settings` for messenger with {\"telegram-chat-id\": \"<the chat's id>\"})")
|
||||
}
|
||||
if !chatIDShape.MatchString(chat) {
|
||||
return "", "", errors.New("the setting telegram-chat-id is not a chat id (a number, or @name of a channel)")
|
||||
}
|
||||
return token, chat, nil
|
||||
}
|
||||
|
||||
func (t *Telegram) token() (string, error) {
|
||||
if t.Config.TokenFile == "" {
|
||||
return "", errors.New("no bot token: this module was started without a token file")
|
||||
}
|
||||
raw, err := os.ReadFile(t.Config.TokenFile)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", errors.New("no bot token: the own secret telegram-token is not on this machine yet " +
|
||||
"(`secret accept <machine> messenger telegram-token`, then push the machine)")
|
||||
}
|
||||
if err != nil {
|
||||
return "", errors.New("the own secret telegram-token cannot be read: " + plainError(err))
|
||||
}
|
||||
token := strings.TrimSpace(string(raw))
|
||||
if token == "" {
|
||||
return "", errors.New("no bot token: the own secret telegram-token is empty")
|
||||
}
|
||||
if !tokenShape.MatchString(token) {
|
||||
// The mesh mints a random value for an own secret nobody accepted; that is not a bot token.
|
||||
return "", errors.New("the own secret telegram-token is not a bot token (digits, a colon, then the key " +
|
||||
"BotFather gave) — most likely the mesh made it because none was accepted: " +
|
||||
"`secret accept <machine> messenger telegram-token`, then push the machine")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Send posts a message and answers its message id.
|
||||
func (t *Telegram) Send(m Message) (string, error) {
|
||||
text := m.Text()
|
||||
token, chat, err := t.ready()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
}
|
||||
if err := t.call(token, "sendMessage", map[string]any{
|
||||
"chat_id": chat, "text": text, "disable_web_page_preview": true,
|
||||
}, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprint(out.MessageID), nil
|
||||
}
|
||||
|
||||
// Edit replaces the text of a message sent before: how a cleared condition is said (to-be 45 §5).
|
||||
func (t *Telegram) Edit(id string, m Message) error {
|
||||
text := m.Text()
|
||||
token, chat, err := t.ready()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.call(token, "editMessageText", map[string]any{
|
||||
"chat_id": chat, "message_id": json.Number(id), "text": text, "disable_web_page_preview": true,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// CanEdit: Telegram edits a message in place.
|
||||
func (t *Telegram) CanEdit() bool { return true }
|
||||
|
||||
// SilentEdit: an edited Telegram message notifies nobody.
|
||||
func (t *Telegram) SilentEdit() bool { return true }
|
||||
|
||||
// Who asks the bot API who it is: the check that the token works, with no message sent.
|
||||
func (t *Telegram) Who() (string, error) {
|
||||
token, _, err := t.ready()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var me struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := t.call(token, "getMe", map[string]any{}, &me); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return me.Username, nil
|
||||
}
|
||||
|
||||
func (t *Telegram) call(token, method string, body map[string]any, into any) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest(http.MethodPost, TelegramAPI+"/bot"+token+"/"+method, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return errors.New("telegram " + method + ": the request could not be made")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := t.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("telegram %s: %s", method, plainError(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var answer struct {
|
||||
OK bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
Description string `json:"description"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&answer); err != nil {
|
||||
return fmt.Errorf("telegram %s: HTTP %d with an answer that is not the bot API's", method, resp.StatusCode)
|
||||
}
|
||||
if !answer.OK {
|
||||
d := strings.ReplaceAll(answer.Description, token, "<token>")
|
||||
return fmt.Errorf("telegram %s refused: %d %s", method, answer.ErrorCode, d)
|
||||
}
|
||||
if into != nil && len(answer.Result) > 0 {
|
||||
_ = json.Unmarshal(answer.Result, into)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// plainError is an error in words, without the URL a transport error carries.
|
||||
func plainError(err error) string {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
var ne net.Error
|
||||
switch {
|
||||
case errors.As(err, &ne) && ne.Timeout():
|
||||
return "no answer in time"
|
||||
case errors.Is(err, os.ErrPermission):
|
||||
return "permission denied"
|
||||
}
|
||||
var dns *net.DNSError
|
||||
if errors.As(err, &dns) {
|
||||
return "the bot API's name does not resolve"
|
||||
}
|
||||
var op *net.OpError
|
||||
if errors.As(err, &op) {
|
||||
return "cannot connect (" + op.Op + ")"
|
||||
}
|
||||
s := err.Error()
|
||||
if strings.Contains(s, "/bot") || strings.Contains(s, "://") {
|
||||
return "the request failed"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -105,8 +105,7 @@ var examples = []example{{
|
||||
body: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, mnt-recalbox " +
|
||||
"(a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh does not repair " +
|
||||
"them.\nSince 12:40.",
|
||||
offers: "Silence for a week, Details", choose: "Silence for a week",
|
||||
asked: `seat:mesh-controller.conditions {"cause":"operator-answer","for":"7d","silence":"machine.shanks.units","why":"chosen on the desktop notification \"3 failed services on shanks\" on g14 (a desk click: whoever was at the operator's session)"}`,
|
||||
offers: "Details", choose: "Details",
|
||||
cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute,
|
||||
}, {
|
||||
name: "a healer wanted",
|
||||
@@ -213,21 +212,12 @@ func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *te
|
||||
tok = a.Token
|
||||
}
|
||||
}
|
||||
outcome, err := h.Chosen("desktop", "g14", tok, "event-1")
|
||||
var done Message
|
||||
if e.choose == DetailsLabel {
|
||||
done = dt.sends[len(dt.sends)-1]
|
||||
if err != nil || outcome != "shown" || len(asked) != 0 || done.Title != "Details: "+e.headline {
|
||||
t.Fatalf("details: %q %v %v %q", outcome, err, asked, done.Title)
|
||||
}
|
||||
} else {
|
||||
if err != nil || outcome != "done" || len(asked) != 1 || asked[0] != e.asked {
|
||||
t.Fatalf("chose %s: %q %v\nasked %v\nwant %s", e.choose, outcome, err, asked, e.asked)
|
||||
}
|
||||
done = dt.edits["g14=1"]
|
||||
if done.Title != e.choose+": done. "+e.headline {
|
||||
t.Errorf("the answer's outcome: %q", done.Title)
|
||||
}
|
||||
// The router performs nothing: Details is shown where it was chosen, and the condition's own actions
|
||||
// are never called from here (novox/hq ADR 0259) — the controller asks for them.
|
||||
outcome, err := h.Chosen("desktop", choiceOf(tok, "g14", c.now()))
|
||||
done := dt.sends[len(dt.sends)-1]
|
||||
if err != nil || outcome != "shown" || len(asked) != 0 || done.Title != "Details: "+e.headline {
|
||||
t.Fatalf("details: %q %v %v %q", outcome, err, asked, done.Title)
|
||||
}
|
||||
c.pass(e.openFor - BurstWindow)
|
||||
cleared, err := DecodeCondition(EventCleared, e.event(c.now(), true))
|
||||
@@ -280,7 +270,7 @@ func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) {
|
||||
if details.Label != DetailsLabel {
|
||||
t.Fatalf("%+v", m.Actions)
|
||||
}
|
||||
if _, err := h.Chosen("desktop", "g14", details.Token, ""); err != nil {
|
||||
if _, err := h.Chosen("desktop", choiceOf(details.Token, "g14", c.now())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shown := dt.sends[len(dt.sends)-1]
|
||||
@@ -288,7 +278,7 @@ func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) {
|
||||
!strings.Contains(shown.Body, "Since: 8 Oct 10:40") {
|
||||
t.Errorf("details: %q / %q", shown.Title, shown.Body)
|
||||
}
|
||||
if _, err := h.Chosen("desktop", "g14", "not-a-token", ""); err == nil {
|
||||
if _, err := h.Chosen("desktop", choiceOf("not-a-token", "g14", c.now())); err == nil {
|
||||
t.Error("an unknown answer was taken")
|
||||
}
|
||||
}
|
||||
@@ -324,3 +314,34 @@ func TestTimesAreSaidInTheOperatorsZone(t *testing.T) {
|
||||
t.Errorf("a zone that does not exist was not said: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A warning kept quiet is said once after a day, and not again (novox/hq ADR 0258 §6).
|
||||
func TestAQuietWarningIsSaidOnceAfterADay(t *testing.T) {
|
||||
h, tg, dt, c, _ := newHolder(t)
|
||||
c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC)
|
||||
e := examples[0]
|
||||
raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false))
|
||||
h.Condition(EventRaised, raised)
|
||||
settle(h, c)
|
||||
if len(dt.sends)+len(tg.sends) != 0 {
|
||||
t.Fatal("said while it needed nothing")
|
||||
}
|
||||
c.pass(QuietRemindAfter - time.Hour)
|
||||
h.Tick()
|
||||
settle(h, c)
|
||||
if len(dt.sends)+len(tg.sends) != 0 {
|
||||
t.Fatal("said before a day")
|
||||
}
|
||||
c.pass(time.Hour)
|
||||
h.Tick()
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Still open after a day: ") {
|
||||
t.Fatalf("not said once after a day: %+v", dt.sends)
|
||||
}
|
||||
c.pass(QuietRemindAfter)
|
||||
h.Tick()
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 1 {
|
||||
t.Errorf("said again: %d", len(dt.sends))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ module messenger
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.10
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"module": "messenger",
|
||||
"version": "1",
|
||||
"slug": "msgr",
|
||||
"runs-as": "messenger",
|
||||
"seats": [
|
||||
{
|
||||
"name": "operator-channel",
|
||||
@@ -9,7 +10,47 @@
|
||||
"serves": [
|
||||
"open",
|
||||
"history",
|
||||
"notify"
|
||||
"notify",
|
||||
"asks"
|
||||
],
|
||||
"accepts": [
|
||||
"ask",
|
||||
"cancel"
|
||||
],
|
||||
"emits": [
|
||||
"decided"
|
||||
],
|
||||
"by-caller": [
|
||||
"ask",
|
||||
"cancel",
|
||||
"decided"
|
||||
],
|
||||
"records": [
|
||||
"asks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "channel",
|
||||
"scope": "mesh",
|
||||
"kinded": true,
|
||||
"accepts": [
|
||||
"show",
|
||||
"edit",
|
||||
"send"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "intake",
|
||||
"scope": "mesh",
|
||||
"kinded": true,
|
||||
"emits": [
|
||||
"choice",
|
||||
"link",
|
||||
"failed",
|
||||
"standing"
|
||||
],
|
||||
"proofs": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -20,43 +61,65 @@
|
||||
"serves": [
|
||||
"open",
|
||||
"history",
|
||||
"notify"
|
||||
"notify",
|
||||
"asks"
|
||||
]
|
||||
}
|
||||
],
|
||||
"uses": [
|
||||
"channel"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-controller.condition-raised",
|
||||
"mesh-controller.condition-changed",
|
||||
"mesh-controller.condition-cleared",
|
||||
"dunst.action-chosen",
|
||||
"dunst.started"
|
||||
"intake.choice",
|
||||
"intake.link",
|
||||
"intake.failed",
|
||||
"intake.standing"
|
||||
],
|
||||
"emits": [
|
||||
"refused"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:node-notifier.send",
|
||||
"seat:mesh-controller.conditions"
|
||||
"seat:mesh-controller.conditions",
|
||||
"seat:mesh-controller.root-free"
|
||||
],
|
||||
"state": [
|
||||
"open",
|
||||
"sent"
|
||||
"sent",
|
||||
{
|
||||
"name": "asks",
|
||||
"ttl-seconds": 2592000
|
||||
},
|
||||
"identities"
|
||||
],
|
||||
"own-secrets": {
|
||||
"telegram-token": "${dir:state}/telegram-token"
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "messenger",
|
||||
"tools": [
|
||||
"messenger_status",
|
||||
"messenger_recent",
|
||||
"messenger_test",
|
||||
"messenger_check"
|
||||
"messenger_check",
|
||||
"messenger_identities",
|
||||
"messenger_unlink"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "messenger",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/messenger"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
"place": ".",
|
||||
"owner": "messenger"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
@@ -64,7 +127,8 @@
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"merge": "json",
|
||||
"content": "{\n \"telegram-chat-id\": \"\",\n \"desktop-machines\": [],\n \"time-zone\": \"\"\n}\n"
|
||||
"content": "{\n \"time-zone\": \"\"\n}\n",
|
||||
"owner": "messenger"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -81,7 +145,7 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json",
|
||||
"MESH_MESSENGER_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token"
|
||||
"MESH_MESSENGER_STATE": "${dir:state}"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# The bundle built here (manifest build.artifacts[0].binary): never committed.
|
||||
/sudo-tools
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
// Who can become root here without a person (novox/hq ADR 0259 §8): the controller's self-check may ask it for
|
||||
// the accounts agents run as and the account the machine's runtime runs commands as.
|
||||
//
|
||||
// **Conservative, as the review of 2026-10-09 asked.** An account can become root without a person when:
|
||||
// - it is root;
|
||||
// - it is a member of a group that is root by another name (docker, disk, root, lxd, incus-admin, libvirt);
|
||||
// - **sudo lists any rule for it at all** — with NOPASSWD or without, for every command or for one. A named
|
||||
// command is root when it can start a shell or write a file (systemctl, tee, bash, pacman, an editor, …),
|
||||
// and no list of safe commands is kept: none is proven safe here. A rule that asks a password is root too,
|
||||
// because sudo keeps a password typed by the person for minutes, during which a program of the same
|
||||
// account needs none. This is the node-engine's own rule for an agent account (ADR 0266: "any sudo rule").
|
||||
//
|
||||
// **What cannot be judged is an error, never a no**: an account this machine does not know, sudo that could not
|
||||
// be asked, a listing that cannot be read. The caller says it was not measured.
|
||||
//
|
||||
// **Measured as the runtime's account**, which these tools run as: an agent that could act as that account
|
||||
// could change this answer, so the controller takes the agent account's verdict from the node-engine, which
|
||||
// runs as root (ADR 0266), and this tool is a reading for a person, not that verdict.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Escalation is one account's answer.
|
||||
type Escalation struct {
|
||||
Account string `json:"account"`
|
||||
Root bool `json:"root_without_a_person"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// rootGroups are groups that are root by another name.
|
||||
var rootGroups = map[string]string{"docker": "the docker group, which can mount the machine's root file system",
|
||||
"disk": "the disk group, which reads and writes the drives raw", "root": "the root group",
|
||||
"lxd": "the lxd group, which runs privileged containers", "incus-admin": "the incus-admin group, which runs privileged containers",
|
||||
"libvirt": "the libvirt group, which runs machines with the host's disks"}
|
||||
|
||||
var accountShape = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
||||
|
||||
// Escalations answers, for each account, whether it can become root here without a person. An account that
|
||||
// cannot be judged fails the whole answer: never a no.
|
||||
func (m *Machine) Escalations(accounts []string) ([]Escalation, error) {
|
||||
if len(accounts) == 0 {
|
||||
return nil, fmt.Errorf("name the accounts to judge")
|
||||
}
|
||||
var out []Escalation
|
||||
for _, a := range accounts {
|
||||
if !accountShape.MatchString(a) {
|
||||
return nil, fmt.Errorf("%q is not an account name", a)
|
||||
}
|
||||
e, err := m.escalation(a)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s cannot be judged, so nothing is said of it: %v", a, err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Machine) escalation(account string) (Escalation, error) {
|
||||
e := Escalation{Account: account}
|
||||
if account == "root" {
|
||||
e.Root, e.Why = true, "it is root"
|
||||
return e, nil
|
||||
}
|
||||
id := m.Run(bg(), "id", "-nG", account)
|
||||
if id.Status != 0 || id.Err != "" {
|
||||
return e, fmt.Errorf("this machine does not know the account: %s", strings.TrimSpace(firstLine(id.Stderr+id.Err)))
|
||||
}
|
||||
for _, g := range strings.Fields(id.Stdout) {
|
||||
if why, ok := rootGroups[g]; ok {
|
||||
e.Root, e.Why = true, "a member of "+why
|
||||
return e, nil
|
||||
}
|
||||
}
|
||||
// sudo's own word, for that account: listing another account's rules needs root, which the runtime's
|
||||
// account escalates to as the mesh's acting tools do.
|
||||
r := m.Run(bg(), "sudo", "-n", "-l", "-U", account)
|
||||
listing := r.Stdout + r.Stderr
|
||||
if strings.Contains(listing, "is not allowed to run sudo") {
|
||||
e.Why = "no sudo rule names it, and no group it is in is root by another name"
|
||||
return e, nil
|
||||
}
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return e, fmt.Errorf("sudo could not be asked: %s", strings.TrimSpace(firstLine(r.Stderr+r.Err)))
|
||||
}
|
||||
rules := ParseList(r.Stdout, account)
|
||||
if !mayRun.MatchString(firstMayRun(r.Stdout)) {
|
||||
return e, fmt.Errorf("sudo's listing could not be read")
|
||||
}
|
||||
if len(rules.Rules) == 0 {
|
||||
e.Why = "sudo lists no rule for it, and no group it is in is root by another name"
|
||||
return e, nil
|
||||
}
|
||||
var lines []string
|
||||
for _, rule := range rules.Rules {
|
||||
lines = append(lines, rule.Line)
|
||||
}
|
||||
how := "a password the person typed is kept by sudo for minutes"
|
||||
for _, rule := range rules.Rules {
|
||||
if hasTag(rule.Tags, "NOPASSWD") {
|
||||
how = "with no password"
|
||||
break
|
||||
}
|
||||
}
|
||||
e.Root, e.Why = true, "sudo has a rule for it ("+strings.Join(lines, "; ")+"), "+how+
|
||||
": any rule counts, since no command is proven unable to reach root"
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// firstMayRun is the listing's "User … may run the following commands on …:" line, or "".
|
||||
func firstMayRun(out string) string {
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l = strings.TrimSpace(l); mayRun.MatchString(l) {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0259 §8, as the review of 2026-10-09 asked: any sudo rule is root without a person — NOPASSWD on
|
||||
// a named command (systemctl, tee, bash, pacman) as much as on all of them, and a rule asking a password too —
|
||||
// and what cannot be judged is an error, never a no.
|
||||
func TestWhoCanBecomeRootWithoutAPerson(t *testing.T) {
|
||||
var calls []call
|
||||
listing := func(account, rule string) Ran {
|
||||
return Ran{Stdout: "User " + account + " may run the following commands on anchor:\n " + rule + "\n"}
|
||||
}
|
||||
run := byLine(map[string]Ran{
|
||||
"id -nG ops": {Stdout: "ops wheel\n"},
|
||||
"sudo -n -l -U ops": listing("ops", "(ALL : ALL) NOPASSWD: ALL"),
|
||||
"id -nG agents": {Stdout: "agents\n"},
|
||||
"sudo -n -l -U agents": {Stdout: "User agents is not allowed to run sudo on anchor.\n", Status: 1},
|
||||
"id -nG builder": {Stdout: "builder docker\n"},
|
||||
"id -nG careful": {Stdout: "careful wheel\n"},
|
||||
"sudo -n -l -U careful": listing("careful", "(ALL : ALL) ALL"),
|
||||
"id -nG units": {Stdout: "units\n"},
|
||||
"sudo -n -l -U units": listing("units", "(root) NOPASSWD: /usr/bin/systemctl"),
|
||||
"id -nG writer": {Stdout: "writer\n"},
|
||||
"sudo -n -l -U writer": listing("writer", "(root) NOPASSWD: /usr/bin/tee /etc/hosts"),
|
||||
"id -nG packager": {Stdout: "packager\n"},
|
||||
"sudo -n -l -U packager": listing("packager", "(root) NOPASSWD: /usr/bin/pacman -Syu"),
|
||||
"id -nG asother": {Stdout: "asother\n"},
|
||||
"sudo -n -l -U asother": listing("asother", "(ops) NOPASSWD: /bin/bash"),
|
||||
"id -nG containers": {Stdout: "containers lxd\n"},
|
||||
}, &calls)
|
||||
got, err := machine(run, 1000).Escalations([]string{"ops", "agents", "builder", "careful", "units", "writer",
|
||||
"packager", "asother", "containers", "root"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]bool{"ops": true, "agents": false, "builder": true, "careful": true, "units": true, "writer": true,
|
||||
"packager": true, "asother": true, "containers": true, "root": true}
|
||||
for _, e := range got {
|
||||
if e.Root != want[e.Account] || e.Why == "" {
|
||||
t.Errorf("%s: root %v (%s), want %v", e.Account, e.Root, e.Why, want[e.Account])
|
||||
}
|
||||
}
|
||||
if _, err := machine(run, 1000).Escalations([]string{"a b"}); err == nil || !strings.Contains(err.Error(), "not an account name") {
|
||||
t.Errorf("an account name that is not one was asked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What cannot be judged is never a no: an unknown account, sudo that cannot be asked, a listing that cannot be
|
||||
// read — each fails the whole answer.
|
||||
func TestWhatCannotBeJudgedIsAnError(t *testing.T) {
|
||||
var calls []call
|
||||
run := byLine(map[string]Ran{
|
||||
"id -nG ghost": {Status: 1, Stderr: "id: 'ghost': no such user"},
|
||||
"id -nG agents": {Stdout: "agents\n"},
|
||||
"sudo -n -l -U agents": {Status: 1, Stderr: "sudo: a password is required"},
|
||||
"id -nG garbled": {Stdout: "garbled\n"},
|
||||
"sudo -n -l -U garbled": {Stdout: "something else entirely\n"},
|
||||
"id -nG fine": {Stdout: "fine\n"},
|
||||
"sudo -n -l -U fine": {Stdout: "User fine is not allowed to run sudo on anchor.\n", Status: 1},
|
||||
}, &calls)
|
||||
for _, a := range []string{"ghost", "agents", "garbled"} {
|
||||
got, err := machine(run, 1000).Escalations([]string{"fine", a})
|
||||
if err == nil || !strings.Contains(err.Error(), a+" cannot be judged") {
|
||||
t.Errorf("%s: answered %+v (%v), want an error", a, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,27 @@ func tools(m *Machine) []stdio.Tool {
|
||||
Input: schema(map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) { return m.CheckEscalation() },
|
||||
},
|
||||
{
|
||||
Name: "sudo_escalation",
|
||||
Description: "Can these accounts become root on this machine without a person: any sudo rule for it " +
|
||||
"(with or without a password, for any command), or a group that is root by another name (docker, disk, " +
|
||||
"root, lxd, incus-admin, libvirt). Each account with yes or no and why; an account that cannot be " +
|
||||
"judged fails the answer, never a no. Read as the runtime's account: a reading for a person — the " +
|
||||
"agent account's verdict is the node-engine's (novox/hq ADR 0259 §8, ADR 0266).",
|
||||
Input: schema(map[string]any{"accounts": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "the accounts to judge"}}),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
var accounts []string
|
||||
if list, ok := a["accounts"].([]any); ok {
|
||||
for _, x := range list {
|
||||
if s, ok := x.(string); ok {
|
||||
accounts = append(accounts, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m.Escalations(accounts)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "sudo_drop_ins",
|
||||
Description: "The files of /etc/sudoers.d with owner, mode and size, whether sudo reads each (a name with a dot " +
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"tools": [
|
||||
"sudo_rules",
|
||||
"sudo_check",
|
||||
"sudo_escalation",
|
||||
"sudo_drop_ins"
|
||||
],
|
||||
"resources": [
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# telegram
|
||||
|
||||
A channel of the operator's conversation: kind `telegram` of the `channel` and `intake` benches the router
|
||||
(`messenger`) declares (novox/hq ADR 0234, ADR 0259). It reaches the operator away from the machines, and it
|
||||
proves who answered: Telegram authenticates the sender, and the router compares it with the account linked
|
||||
as the operator.
|
||||
|
||||
- **Shows what the router sends** (its kind's worker on the `channel` queue): a message, a question with
|
||||
buttons — each button carries only the router's one-time ticket — or an edit of a message shown before.
|
||||
It sends to the account the router names as linked, or, before one is, to the chat a `/start` came from.
|
||||
- **Says what the operator chose** on `intake` under its own kind: a tapped button (`choice`), with the
|
||||
sender as Telegram authenticated it; `/start` (`link`); a message it could not deliver (`failed`); and
|
||||
whether it can send (`standing`). Only a private chat with the account itself counts.
|
||||
- **A code** (six digits, the one shown on the desk when linking) is asked of the router as a proof, by
|
||||
request and reply: never in an event, never kept, and deleted from the chat at once.
|
||||
- **Judges nothing.** Whether the sender is the operator, and what an answer may do, is the router's.
|
||||
|
||||
## The defects of the client it replaces (novox/hq to-be 46 §12)
|
||||
|
||||
| | fixed here |
|
||||
|---|---|
|
||||
| D1 | up to 4096 characters arrive whole; longer is cut and the cut said |
|
||||
| D4 | a warning or a clearing arrives without sound; an urgent message or a question rings |
|
||||
| D5 | a rate limit is waited out as told |
|
||||
| D6 | a refusal is a permanent failure, said to the router and not retried; an edit to the same text is a success |
|
||||
| D7 | the deprecated preview flag is gone |
|
||||
| D8 | a request that cannot be written is said |
|
||||
| D10 | `standing` checks the token with Telegram (`getMe`), not only that a value is present |
|
||||
|
||||
D2 and D3 (a reopening said by an edit; a clearing edits the first message) are the router's, and D9 the
|
||||
watcher's.
|
||||
|
||||
## How it keeps the token from the agents (novox/hq ADR 0259 §8, as revised)
|
||||
|
||||
- **It runs as the account `telegram`, in a runtime of its own, on a bus account of its own** (`runs-as`). The
|
||||
machine's runtime runs as the operator's account — which every agent runs as — and never carries it; the
|
||||
controller refuses to compose it there.
|
||||
- **Its state directory, its bot token and its bus credential belong to `telegram`**, 0700 and 0600
|
||||
(`secrets-owner`). The operator's account reads none of them, and cannot attach to its process.
|
||||
- **Only this kind's holder says this kind's choices, links and codes**: the bus grants those subjects to its
|
||||
own account alone.
|
||||
- **The router believes it proves its sender only so placed**, and only while the controller's self-check
|
||||
finds no agent able to become root without a person on its machine or the router's (`agent-root`).
|
||||
- **If an agent holds root on the machine, none of this holds**: root reads the token and every credential.
|
||||
- **A tap counts only on a message showing the words it put there.** It keeps a digest of what each message
|
||||
shows; a tap on one whose words differ — changed by another holder of the token — or on one it never showed
|
||||
is refused on the phone and never said to the router.
|
||||
|
||||
## Where an approval from Telegram means something (a known gap, accepted by the operator on 2026-10-09)
|
||||
|
||||
The router does not measure or enforce what follows, and does not hold approvals back for it: the operator
|
||||
accepted the gap for now, and novox/hq issue 344 records it with what closes it.
|
||||
|
||||
An approval from Telegram proves only that the linked account tapped. It is meaningful where nothing working
|
||||
for the operator can tap in their name, which on a machine where agents run needs all three of:
|
||||
|
||||
1. **The agents run under an account of their own, without sudo** (novox/hq ADR 0266), so they cannot become
|
||||
root or the operator.
|
||||
2. **That account has no access to the operator's X display** — no Xauthority cookie for it. X11 has no
|
||||
isolation between the clients of one display: a program on it reads every key typed and can click any
|
||||
window, the Telegram client's buttons among them.
|
||||
3. **The operator's Telegram Desktop data is readable by the operator alone** (`~/.local/share/TelegramDesktop/tdata`,
|
||||
0700). Found 0755 with its files 0644: any account on the machine can copy the session and be the operator
|
||||
on Telegram from elsewhere.
|
||||
|
||||
Until agents have their own account on a machine, the operator's Telegram client on that machine is not
|
||||
counted as safe: **approve from the phone.** No module of the catalogue installs Telegram Desktop today, so
|
||||
nothing of the mesh keeps `tdata` 0700 yet; the operator sets it by hand (`chmod -R go-rwx` on it) until one
|
||||
does.
|
||||
|
||||
## Where it runs
|
||||
|
||||
Held once for the mesh: assign it to the control node, the stably reachable one. Long polling is outbound
|
||||
only, so no route into the mesh is needed, and a stolen token can read updates (a conflict says so) but
|
||||
never inject one.
|
||||
|
||||
## What the operator gives
|
||||
|
||||
1. **A bot**: in Telegram, talk to @BotFather, `/newbot`, a name and a username. BotFather answers with the
|
||||
bot's token. Keep it on the phone; never paste it into a chat with an agent.
|
||||
2. **The token, to the vault**, as this module's own secret `telegram-token`: the vault's verb for a secret
|
||||
given at the desk, or `secret accept <control node> telegram telegram-token` at a terminal on the control
|
||||
node, which reads it without echoing it. Then push the control node.
|
||||
3. **The link**: send the bot `/start`. A six-digit code appears on the desk; send it to the bot within ten
|
||||
minutes. The account answers acknowledgements at once and approves an hour later; every channel says so.
|
||||
`messenger_identities` shows it. `messenger_unlink` asks the linked account itself whether to unlink it.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `telegram_status` | whether it can send and why not (never the token), the bot's name, when updates last arrived, the last delivery and error, counts since start, and its rules |
|
||||
@@ -0,0 +1,290 @@
|
||||
package main
|
||||
|
||||
// The Bot API, as this channel uses it. The bot token is this module's own secret, read from its file at
|
||||
// each use, owned by the account this module runs as and by no other (novox/hq ADR 0259 §8). It is never
|
||||
// said: an error from the HTTP client carries the URL, and the URL carries the token, so every error is
|
||||
// rebuilt here from its kind before it leaves this file, and a description the API gives back has the
|
||||
// token taken out.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// API is where the Bot API answers; a test points it elsewhere.
|
||||
var API = "https://api.telegram.org"
|
||||
|
||||
var tokenShape = regexp.MustCompile(`^\d{5,}:[A-Za-z0-9_-]{30,}$`)
|
||||
|
||||
// Bot calls the Bot API with the token in its file.
|
||||
type Bot struct {
|
||||
TokenFile string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// apiError is the API's refusal: its code, its words, and how long it asks to be left alone.
|
||||
type apiError struct {
|
||||
Method string
|
||||
Code int
|
||||
Words string
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string {
|
||||
return fmt.Sprintf("telegram %s refused: %d %s", e.Method, e.Code, e.Words)
|
||||
}
|
||||
|
||||
// permanent says whether trying again cannot help: a refusal of the request itself (400, 403) or of the
|
||||
// token (401, 404), never a rate limit or the service failing (D6).
|
||||
func permanent(err error) bool {
|
||||
var a *apiError
|
||||
return errors.As(err, &a) && (a.Code == 400 || a.Code == 401 || a.Code == 403 || a.Code == 404)
|
||||
}
|
||||
|
||||
func retryAfter(err error) time.Duration {
|
||||
var a *apiError
|
||||
if errors.As(err, &a) && a.Code == 429 {
|
||||
if a.RetryAfter > 0 {
|
||||
return a.RetryAfter
|
||||
}
|
||||
return time.Second
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// notModified is an edit to the text it already has: a success, never a reason to send again (D6).
|
||||
func notModified(err error) bool {
|
||||
var a *apiError
|
||||
return errors.As(err, &a) && a.Code == 400 && strings.Contains(a.Words, "message is not modified")
|
||||
}
|
||||
|
||||
func gone(err error) bool {
|
||||
var a *apiError
|
||||
return errors.As(err, &a) && a.Code == 400 && (strings.Contains(a.Words, "message to edit not found") ||
|
||||
strings.Contains(a.Words, "message to delete not found"))
|
||||
}
|
||||
|
||||
// token reads the bot token, and says in words what is missing when it cannot: never the value.
|
||||
func (b *Bot) token() (string, error) {
|
||||
if b.TokenFile == "" {
|
||||
return "", errors.New("no bot token: this module was started without a token file")
|
||||
}
|
||||
raw, err := os.ReadFile(b.TokenFile)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", errors.New("no bot token: the own secret telegram-token is not on this machine yet; the operator " +
|
||||
"gives it to the vault (the module's README says how)")
|
||||
}
|
||||
if err != nil {
|
||||
return "", errors.New("the own secret telegram-token cannot be read: " + plainError(err))
|
||||
}
|
||||
token := strings.TrimSpace(string(raw))
|
||||
if token == "" {
|
||||
return "", errors.New("no bot token: the own secret telegram-token is empty")
|
||||
}
|
||||
if !tokenShape.MatchString(token) {
|
||||
// The mesh makes a random value for an own secret nobody gave; that is not a bot token.
|
||||
return "", errors.New("the own secret telegram-token is not a bot token (digits, a colon, then the key " +
|
||||
"BotFather gave): most likely the mesh made it because none was given; the operator gives it to the vault")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// call asks the API one method, decoding its result into `into`.
|
||||
func (b *Bot) call(method string, body map[string]any, into any) error {
|
||||
token, err := b.token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
// Said, never discarded (D8).
|
||||
return fmt.Errorf("telegram %s: the request could not be written: %v", method, err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, API+"/bot"+token+"/"+method, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return errors.New("telegram " + method + ": the request could not be made")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := b.Client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("telegram %s: %s", method, plainError(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var answer struct {
|
||||
OK bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
Description string `json:"description"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Parameters struct {
|
||||
RetryAfter int `json:"retry_after"`
|
||||
} `json:"parameters"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&answer); err != nil {
|
||||
return fmt.Errorf("telegram %s: HTTP %d with an answer that is not the Bot API's", method, resp.StatusCode)
|
||||
}
|
||||
if !answer.OK {
|
||||
return &apiError{Method: method, Code: answer.ErrorCode, Words: strings.ReplaceAll(answer.Description, token, "<token>"),
|
||||
RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second}
|
||||
}
|
||||
if into != nil && len(answer.Result) > 0 {
|
||||
if err := json.Unmarshal(answer.Result, into); err != nil {
|
||||
return fmt.Errorf("telegram %s: its answer could not be read: %v", method, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// plainError is an error in words, without the URL a transport error carries.
|
||||
func plainError(err error) string {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
err = ue.Err
|
||||
}
|
||||
var ne net.Error
|
||||
switch {
|
||||
case errors.As(err, &ne) && ne.Timeout():
|
||||
return "no answer in time"
|
||||
case errors.Is(err, os.ErrPermission):
|
||||
return "permission denied"
|
||||
}
|
||||
var dns *net.DNSError
|
||||
if errors.As(err, &dns) {
|
||||
return "the Bot API's name does not resolve"
|
||||
}
|
||||
var op *net.OpError
|
||||
if errors.As(err, &op) {
|
||||
return "cannot connect (" + op.Op + ")"
|
||||
}
|
||||
s := err.Error()
|
||||
if strings.Contains(s, "/bot") || strings.Contains(s, "://") {
|
||||
return "the request failed"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// User is a Telegram account as the API says it.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
IsBot bool `json:"is_bot"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// Display is the name the service shows for an account.
|
||||
func (u User) Display() string {
|
||||
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||
if u.Username != "" {
|
||||
if name != "" {
|
||||
return name + " (@" + u.Username + ")"
|
||||
}
|
||||
return "@" + u.Username
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// Chat is where a message was written.
|
||||
type Chat struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Msg is a message as the API says it.
|
||||
type Msg struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
From *User `json:"from"`
|
||||
Chat Chat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Callback is a button tapped.
|
||||
type Callback struct {
|
||||
ID string `json:"id"`
|
||||
From User `json:"from"`
|
||||
Message *Msg `json:"message"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// Update is one thing that happened to the bot.
|
||||
type Update struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *Msg `json:"message"`
|
||||
Callback *Callback `json:"callback_query"`
|
||||
}
|
||||
|
||||
// Updates long-polls for what happened since offset: outbound only, so no route into the mesh is needed, and
|
||||
// a stolen token can read updates (a conflict says so) but never inject one (novox/hq ADR 0234).
|
||||
func (b *Bot) Updates(offset int64, wait time.Duration) ([]Update, error) {
|
||||
var out []Update
|
||||
err := b.call("getUpdates", map[string]any{"offset": offset, "timeout": int(wait.Seconds()),
|
||||
"allowed_updates": []string{"message", "callback_query"}}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Me is the bot's own account: the check that the token works, with nothing sent.
|
||||
func (b *Bot) Me() (User, error) {
|
||||
var me User
|
||||
return me, b.call("getMe", map[string]any{}, &me)
|
||||
}
|
||||
|
||||
// keyboard is buttons one to a row, each carrying only its ticket.
|
||||
func keyboard(buttons [][2]string) map[string]any {
|
||||
rows := [][]map[string]string{}
|
||||
for _, b := range buttons {
|
||||
rows = append(rows, []map[string]string{{"text": b[0], "callback_data": b[1]}})
|
||||
}
|
||||
return map[string]any{"inline_keyboard": rows}
|
||||
}
|
||||
|
||||
// Send posts a message and answers its id. silent: it arrives without sound (D4).
|
||||
func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (int64, error) {
|
||||
body := map[string]any{"chat_id": chat, "text": text, "disable_notification": silent,
|
||||
"link_preview_options": map[string]any{"is_disabled": true}}
|
||||
if len(buttons) > 0 {
|
||||
body["reply_markup"] = keyboard(buttons)
|
||||
}
|
||||
var out Msg
|
||||
err := b.call("sendMessage", body, &out)
|
||||
return out.MessageID, err
|
||||
}
|
||||
|
||||
// Edit replaces a message's text and buttons; none given, the buttons go.
|
||||
func (b *Bot) Edit(chat, id int64, text string, buttons [][2]string) error {
|
||||
err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text,
|
||||
"reply_markup": keyboard(buttons), "link_preview_options": map[string]any{"is_disabled": true}}, nil)
|
||||
if notModified(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// AnswerTap ends the phone's spinner for a tapped button, at once.
|
||||
func (b *Bot) AnswerTap(id, text string) error {
|
||||
body := map[string]any{"callback_query_id": id}
|
||||
if text != "" {
|
||||
body["text"] = text
|
||||
}
|
||||
return b.call("answerCallbackQuery", body, nil)
|
||||
}
|
||||
|
||||
// Delete removes a message from the chat: a code once read.
|
||||
func (b *Bot) Delete(chat, id int64) error {
|
||||
err := b.call("deleteMessage", map[string]any{"chat_id": chat, "message_id": id}, nil)
|
||||
if gone(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package main
|
||||
|
||||
// The Telegram channel: kind `telegram` of the `channel` and `intake` benches (novox/hq ADR 0259). It shows
|
||||
// what the router sends and says what the operator chose, and nothing else: it never judges whether the
|
||||
// sender is the operator (the router does, from its list), never calls another module's verb, and knows
|
||||
// nothing of what an answer does.
|
||||
//
|
||||
// - **Work** comes from its kind's worker on the `channel` queue: `show` (a message with buttons, each
|
||||
// carrying only the router's ticket), `send` (words), `edit` (a message shown before, by the router's
|
||||
// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). A warning or a
|
||||
// clearing arrives without sound, an urgent message or a question rings (D4). A rate limit is waited out
|
||||
// as told (D5); a refusal is a permanent failure, said to the router, never retried; an edit to the same
|
||||
// text is a success (D6). A message that cannot be delivered after three tries is said as failed.
|
||||
// - **What arrives** is long-polled: outbound only. A tapped button is acknowledged at once (the phone's
|
||||
// spinner stops) and said as a `choice` with the sender the service authenticated; only a private chat
|
||||
// with the account itself counts, and only on a message showing the words this channel put there (another
|
||||
// holder of the token may have changed them). `/start` is said as a `link`; six digits are asked as a `code` proof by
|
||||
// request and reply, never in an event, and deleted from the chat. Anything else is answered with one
|
||||
// fixed line.
|
||||
// - **Standing** is said every five minutes and whenever it changes: whether the token works, whether
|
||||
// the updates arrive, and that an edit here notifies nobody.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Kind is this channel's kind on both benches.
|
||||
const Kind = "telegram"
|
||||
|
||||
const (
|
||||
// MaxLength is what a message carries whole (Telegram's own bound).
|
||||
MaxLength = 4096
|
||||
// cutNote is said where a message was cut.
|
||||
cutNote = "\n… (cut here: the rest is in the mesh's history)"
|
||||
// Tries is how often a message is tried before it is said as failed.
|
||||
Tries = 3
|
||||
// PollWait is how long one long poll waits for something to happen.
|
||||
PollWait = 50 * time.Second
|
||||
// StandingEvery is how often the channel says whether it can send, at least.
|
||||
StandingEvery = 5 * time.Minute
|
||||
// FixedLine is what anything the bot does not take is answered with.
|
||||
FixedLine = "This bot carries the mesh's messages and takes their buttons. To link this account as the " +
|
||||
"operator, send /start."
|
||||
)
|
||||
|
||||
var codeShape = regexp.MustCompile(`^\s*(\d{3})\s?(\d{3})\s*$`)
|
||||
|
||||
// Placed is where one of the router's messages is in the chat.
|
||||
type Placed struct {
|
||||
Chat int64 `json:"chat"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// Store is this module's state on the bus: the update offset and where each message is.
|
||||
type Store interface {
|
||||
Offset() (int64, error)
|
||||
SetOffset(int64) error
|
||||
Placed(handle string) (*Placed, error)
|
||||
Place(handle string, p Placed) error
|
||||
// Shown is the digest of the words a message in a chat shows as this channel last put them there (wordsDigest),
|
||||
// empty for one it never showed; Show keeps it.
|
||||
Shown(chat, id int64) (string, error)
|
||||
Show(chat, id int64, digest string) error
|
||||
}
|
||||
|
||||
// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — its line
|
||||
// endings and outer spaces aside — so a message whose words another holder of the token changed is told
|
||||
// from the one this channel showed (the review of 2026-10-09).
|
||||
func wordsDigest(words string) string {
|
||||
w := strings.TrimSpace(strings.ReplaceAll(words, "\r\n", "\n"))
|
||||
sum := sha256.Sum256([]byte(w))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// keepShown keeps what a message now shows; a message whose words could not be kept answers no tap.
|
||||
func (c *Channel) keepShown(chat, id int64, words string) {
|
||||
if err := c.Store.Show(chat, id, wordsDigest(words)); err != nil {
|
||||
c.Logf("[telegram] what a message shows could not be kept, so its buttons will be refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Channel is the channel's whole working.
|
||||
type Channel struct {
|
||||
Bot *Bot
|
||||
Store Store
|
||||
Publish func(subject string, body any, id string) error
|
||||
Prove func(subject string, body any) (json.RawMessage, error)
|
||||
Now func() time.Time
|
||||
Logf func(string, ...any)
|
||||
Sleep func(time.Duration)
|
||||
|
||||
mu sync.Mutex
|
||||
replyTo map[string]int64 // an arrival's id to the chat it came from, for the router's reply
|
||||
lastPoll time.Time
|
||||
pollErr string
|
||||
meAt time.Time
|
||||
me string
|
||||
meErr string
|
||||
said asks.Standing
|
||||
saidAt time.Time
|
||||
lastSent time.Time
|
||||
lastErr string
|
||||
delivered int
|
||||
failed int
|
||||
}
|
||||
|
||||
func (c *Channel) init() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.replyTo == nil {
|
||||
c.replyTo = map[string]int64{}
|
||||
}
|
||||
if c.Now == nil {
|
||||
c.Now = time.Now
|
||||
}
|
||||
if c.Logf == nil {
|
||||
c.Logf = func(string, ...any) {}
|
||||
}
|
||||
if c.Sleep == nil {
|
||||
c.Sleep = time.Sleep
|
||||
}
|
||||
}
|
||||
|
||||
// text is a message as the chat shows it, cut at MaxLength and the cut said (D1).
|
||||
func text(m asks.Message) string {
|
||||
t := m.Title
|
||||
if m.Body != "" {
|
||||
t += "\n" + m.Body
|
||||
}
|
||||
if utf8.RuneCountInString(t) <= MaxLength {
|
||||
return t
|
||||
}
|
||||
runes := []rune(t)
|
||||
keep := MaxLength - utf8.RuneCountInString(cutNote)
|
||||
return string(runes[:keep]) + cutNote
|
||||
}
|
||||
|
||||
// buttons are a message's buttons as the API takes them: a label and the ticket, which must fit the 64
|
||||
// bytes Telegram allows; one that does not is left out and said.
|
||||
func (c *Channel) buttons(m asks.Message) [][2]string {
|
||||
var out [][2]string
|
||||
for _, b := range m.Buttons {
|
||||
if len(b.Ticket) == 0 || len(b.Ticket) > 64 {
|
||||
c.Logf("[telegram] a button %q carries a ticket Telegram cannot hold; left out", b.Label)
|
||||
continue
|
||||
}
|
||||
out = append(out, [2]string{b.Label, b.Ticket})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chatFor is where a message goes: the linked account the router names, else the chat of the arrival it
|
||||
// replies to. Neither is a permanent failure: nobody is linked, and nobody asked.
|
||||
func (c *Channel) chatFor(m asks.Message) (int64, error) {
|
||||
if m.To != "" {
|
||||
id, err := strconv.ParseInt(m.To, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("the router names %q as the account, which is not a Telegram account's id", m.To)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
chat, ok := c.replyTo[m.Reply]
|
||||
c.mu.Unlock()
|
||||
if m.Reply != "" && ok {
|
||||
return chat, nil
|
||||
}
|
||||
return 0, errors.New("no chat to send to: no account is linked as the operator on Telegram")
|
||||
}
|
||||
|
||||
// Take does one piece of the channel's work. It returns an error only to have the work offered again:
|
||||
// a message delivered, or said as failed, is taken.
|
||||
func (c *Channel) Take(subject string, body json.RawMessage) error {
|
||||
c.init()
|
||||
parts := strings.Split(subject, ".")
|
||||
if len(parts) != 6 || parts[5] != Kind {
|
||||
c.Logf("[telegram] work on %s is not this kind's; taken and dropped", subject)
|
||||
return nil
|
||||
}
|
||||
verb := parts[4]
|
||||
var m asks.Message
|
||||
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
|
||||
c.Logf("[telegram] work on %s could not be read; taken and dropped", subject)
|
||||
return nil
|
||||
}
|
||||
chat, err := c.chatFor(m)
|
||||
if err != nil {
|
||||
c.fail(m.Handle, err, true)
|
||||
return nil
|
||||
}
|
||||
words, buttons := text(m), c.buttons(m)
|
||||
silent := m.Silent || (!m.Urgent && len(buttons) == 0)
|
||||
var place *Placed
|
||||
if verb == asks.Edit {
|
||||
if place, err = c.Store.Placed(m.Handle); err != nil {
|
||||
return err // the state is away: offered again
|
||||
}
|
||||
}
|
||||
for try := 1; ; try++ {
|
||||
var id int64
|
||||
switch {
|
||||
case place != nil:
|
||||
err = c.Bot.Edit(place.Chat, place.ID, words, buttons)
|
||||
if err == nil || notModified(err) {
|
||||
c.keepShown(place.Chat, place.ID, words)
|
||||
}
|
||||
if gone(err) {
|
||||
place, err = nil, c.newMessage(m.Handle, chat, words, buttons, silent, &id)
|
||||
}
|
||||
default:
|
||||
err = c.newMessage(m.Handle, chat, words, buttons, silent, &id)
|
||||
}
|
||||
if err == nil {
|
||||
c.mu.Lock()
|
||||
c.lastSent, c.lastErr = c.Now(), ""
|
||||
c.delivered++
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if wait := retryAfter(err); wait > 0 && try < Tries {
|
||||
c.Sleep(min(wait, 30*time.Second))
|
||||
continue
|
||||
}
|
||||
if permanent(err) {
|
||||
c.fail(m.Handle, err, true)
|
||||
return nil
|
||||
}
|
||||
if try >= Tries {
|
||||
c.fail(m.Handle, err, false)
|
||||
return nil
|
||||
}
|
||||
c.Sleep(time.Duration(try*try) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Channel) newMessage(handle string, chat int64, words string, buttons [][2]string, silent bool, id *int64) error {
|
||||
got, err := c.Bot.Send(chat, words, buttons, silent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*id = got
|
||||
c.keepShown(chat, got, words)
|
||||
if err := c.Store.Place(handle, Placed{Chat: chat, ID: got}); err != nil {
|
||||
c.Logf("[telegram] a message was sent and where it is could not be kept: an edit of it will be a new message: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fail says to the router that a message did not arrive, and why.
|
||||
func (c *Channel) fail(handle string, err error, forGood bool) {
|
||||
c.mu.Lock()
|
||||
c.lastErr = err.Error()
|
||||
c.failed++
|
||||
c.mu.Unlock()
|
||||
c.Logf("[telegram] a message was not delivered: %v", err)
|
||||
f := asks.Failed{Handle: handle, Why: err.Error(), Permanent: forGood, At: c.Now()}
|
||||
if perr := c.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+handle+"."+strconv.FormatInt(c.Now().UnixNano(), 10)); perr != nil {
|
||||
c.Logf("[telegram] and the router could not be told: %v", perr)
|
||||
}
|
||||
}
|
||||
|
||||
// private says whether an arrival is from an account in its own private chat with the bot: anything in a
|
||||
// group, or from another bot, is not the operator's own, whoever the router would say they are.
|
||||
func private(from *User, chat Chat) bool {
|
||||
return from != nil && !from.IsBot && chat.Type == "private" && chat.ID == from.ID
|
||||
}
|
||||
|
||||
func senderOf(u User) asks.Sender {
|
||||
return asks.Sender{Identity: strconv.FormatInt(u.ID, 10), Display: u.Display(), Authenticated: true}
|
||||
}
|
||||
|
||||
// Handle takes one update.
|
||||
func (c *Channel) Handle(u Update) {
|
||||
c.init()
|
||||
now := c.Now()
|
||||
switch {
|
||||
case u.Callback != nil:
|
||||
cb := u.Callback
|
||||
if cb.Message == nil || !private(&cb.From, cb.Message.Chat) {
|
||||
c.answerTap(cb.ID, "")
|
||||
c.Logf("[telegram] a tap outside a private chat was dropped")
|
||||
return
|
||||
}
|
||||
// Only on a message showing exactly what this channel put there: one whose words another holder of
|
||||
// the token changed, or one it never showed, answers nothing (the review of 2026-10-09).
|
||||
if want, err := c.Store.Shown(cb.Message.Chat.ID, cb.Message.MessageID); err != nil || want == "" ||
|
||||
want != wordsDigest(cb.Message.Text) {
|
||||
c.answerTap(cb.ID, "That answer was not taken: this message does not show what the mesh put there. Nothing was done.")
|
||||
c.Logf("[telegram] a tap on a message not showing what this channel put there was refused")
|
||||
return
|
||||
}
|
||||
// At once, whatever comes of it: the phone shows a spinner until then.
|
||||
c.answerTap(cb.ID, "")
|
||||
id := "tg-tap-" + cb.ID
|
||||
c.remember(id, cb.Message.Chat.ID)
|
||||
ch := asks.Choice{ID: id, Ticket: cb.Data, Sender: senderOf(cb.From), At: now}
|
||||
if err := c.Publish(asks.IntakeSubject(asks.Chosen, Kind), ch, id); err != nil {
|
||||
c.Logf("[telegram] a tap could not be said to the router: %v", err)
|
||||
_ = c.Bot.AnswerTap(cb.ID, "The mesh could not take this answer now. Try again in a minute.")
|
||||
}
|
||||
case u.Message != nil:
|
||||
m := u.Message
|
||||
if !private(m.From, m.Chat) {
|
||||
return // a group, a channel post, another bot: nothing here is the operator's own
|
||||
}
|
||||
id := fmt.Sprintf("tg-msg-%d", u.UpdateID)
|
||||
c.remember(id, m.Chat.ID)
|
||||
switch t := strings.TrimSpace(m.Text); {
|
||||
case t == "/start" || strings.HasPrefix(t, "/start "):
|
||||
l := asks.LinkAsked{ID: id, Sender: senderOf(*m.From), At: now}
|
||||
if err := c.Publish(asks.IntakeSubject(asks.Link, Kind), l, id); err != nil {
|
||||
c.reply(m.Chat.ID, "The mesh could not take this now. Try again in a minute.")
|
||||
}
|
||||
case codeShape.MatchString(t):
|
||||
code := codeShape.ReplaceAllString(t, "$1$2")
|
||||
// Out of the chat as soon as it is read (ADR 0234: deleted where the service allows).
|
||||
if err := c.Bot.Delete(m.Chat.ID, m.MessageID); err != nil {
|
||||
c.Logf("[telegram] a code could not be deleted from the chat: %v", err)
|
||||
}
|
||||
raw, err := c.Prove(asks.ProofSubject(asks.CodeProof, Kind), asks.Code{Sender: senderOf(*m.From), Code: code, Purpose: "link"})
|
||||
if err != nil {
|
||||
c.reply(m.Chat.ID, "The mesh could not check the code now. Try again in a minute.")
|
||||
return
|
||||
}
|
||||
var a asks.ProofAnswer
|
||||
if json.Unmarshal(raw, &a) != nil || a.Words == "" {
|
||||
a.Words = "The mesh's answer could not be read."
|
||||
}
|
||||
c.reply(m.Chat.ID, a.Words)
|
||||
default:
|
||||
c.reply(m.Chat.ID, FixedLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Channel) answerTap(id, words string) {
|
||||
if err := c.Bot.AnswerTap(id, words); err != nil {
|
||||
c.Logf("[telegram] a tap could not be answered: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Channel) remember(id string, chat int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.replyTo) > 500 {
|
||||
c.replyTo = map[string]int64{}
|
||||
}
|
||||
c.replyTo[id] = chat
|
||||
}
|
||||
|
||||
func (c *Channel) reply(chat int64, words string) {
|
||||
if _, err := c.Bot.Send(chat, words, nil, true); err != nil {
|
||||
c.Logf("[telegram] could not answer in the chat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll asks for what happened once, and takes it. A conflict is another program taking this bot's
|
||||
// updates: a webhook set, or the token in other hands.
|
||||
func (c *Channel) Poll(wait time.Duration) error {
|
||||
c.init()
|
||||
offset, err := c.Store.Offset()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates, err := c.Bot.Updates(offset, wait)
|
||||
c.mu.Lock()
|
||||
if err != nil {
|
||||
var a *apiError
|
||||
if errors.As(err, &a) && a.Code == 409 {
|
||||
err = errors.New("another program takes this bot's updates (a webhook, or the token in other hands): " + a.Words)
|
||||
}
|
||||
c.pollErr = err.Error()
|
||||
} else {
|
||||
c.pollErr, c.lastPoll = "", c.Now()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range updates {
|
||||
c.Handle(u)
|
||||
if u.UpdateID >= offset {
|
||||
offset = u.UpdateID + 1
|
||||
}
|
||||
// Kept after each update, not after the batch: a restart in the middle takes again only the one it
|
||||
// was handling (the review of 2026-10-08), and the router acts on an answer once by its id anyway.
|
||||
if err := c.Store.SetOffset(offset); err != nil {
|
||||
c.Logf("[telegram] the update offset could not be kept; a restart may take some updates again: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Standing is whether the channel can send now: the token works and the updates arrive.
|
||||
func (c *Channel) Standing() asks.Standing {
|
||||
c.init()
|
||||
now := c.Now()
|
||||
s := asks.Standing{EditsSilently: true, At: now}
|
||||
if _, err := c.Bot.token(); err != nil {
|
||||
s.Why = err.Error()
|
||||
return s
|
||||
}
|
||||
c.mu.Lock()
|
||||
stale := now.Sub(c.meAt) >= StandingEvery
|
||||
c.mu.Unlock()
|
||||
if stale {
|
||||
me, err := c.Bot.Me()
|
||||
c.mu.Lock()
|
||||
c.meAt = now
|
||||
c.me, c.meErr = me.Username, ""
|
||||
if err != nil {
|
||||
c.meErr = err.Error()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
switch {
|
||||
case c.meErr != "":
|
||||
s.Why = "the bot token does not work: " + c.meErr
|
||||
case c.pollErr != "":
|
||||
s.Why = "the updates do not arrive: " + c.pollErr
|
||||
default:
|
||||
s.Ready = true
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SayStanding says the standing when it changed, or every StandingEvery.
|
||||
func (c *Channel) SayStanding() {
|
||||
s := c.Standing()
|
||||
c.mu.Lock()
|
||||
same := s.Ready == c.said.Ready && s.Why == c.said.Why && s.At.Sub(c.saidAt) < StandingEvery
|
||||
c.mu.Unlock()
|
||||
if same {
|
||||
return
|
||||
}
|
||||
if err := c.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
|
||||
c.Logf("[telegram] could not say whether it can send: %v", err)
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.said, c.saidAt = s, s.At
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Status is the channel's account of itself, for the tool.
|
||||
func (c *Channel) Status() map[string]any {
|
||||
s := c.Standing()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := map[string]any{"kind": Kind, "can_send": s.Ready, "delivered_since_start": c.delivered,
|
||||
"failed_since_start": c.failed, "edits_notify_nobody": true}
|
||||
if s.Why != "" {
|
||||
out["why_not"] = s.Why
|
||||
}
|
||||
if c.me != "" {
|
||||
out["bot"] = "@" + c.me
|
||||
}
|
||||
if !c.lastPoll.IsZero() {
|
||||
out["updates_last_arrived"] = c.lastPoll.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !c.lastSent.IsZero() {
|
||||
out["last_delivered"] = c.lastSent.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if c.lastErr != "" {
|
||||
out["last_error"] = c.lastErr
|
||||
}
|
||||
out["rules"] = []string{
|
||||
"it shows what the router sends and says what was chosen; whether the sender is the operator is the router's to judge, from the account linked there (novox/hq ADR 0259)",
|
||||
"a button carries only the router's one-time ticket",
|
||||
"only a private chat with the account itself counts; a tap is acknowledged at once",
|
||||
"/start asks to link this account; the code shown on the desk is checked by request and reply, never in an event, and deleted from the chat",
|
||||
"updates are long-polled: outbound only, no route into the mesh",
|
||||
"up to 4096 characters arrive whole; longer is cut and the cut said",
|
||||
"warnings and clearings arrive without sound; urgent messages and questions ring",
|
||||
"a rate limit is waited out; a refusal is said to the router as permanent; an edit to the same text is a success",
|
||||
"it runs as its own account, in a runtime of its own on a bus account of its own, never the machine's runtime; that account alone reads the bot token",
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
const goodToken = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
|
||||
|
||||
// fakeAPI is the Bot API, answering each method as the test says and keeping what it was asked.
|
||||
type fakeAPI struct {
|
||||
mu sync.Mutex
|
||||
calls []call
|
||||
answer map[string]func(body map[string]any) (int, string)
|
||||
next int64
|
||||
}
|
||||
|
||||
type call struct {
|
||||
Method string
|
||||
Body map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeAPI) of(method string) []call {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []call
|
||||
for _, c := range f.calls {
|
||||
if c.Method == method {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newAPI(t *testing.T) *fakeAPI {
|
||||
t.Helper()
|
||||
f := &fakeAPI{answer: map[string]func(map[string]any) (int, string){}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
method := parts[len(parts)-1]
|
||||
if parts[1] != "bot"+goodToken {
|
||||
w.WriteHeader(401)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error_code":401,"description":"Unauthorized"}`))
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, call{method, body})
|
||||
answer := f.answer[method]
|
||||
f.next++
|
||||
id := f.next
|
||||
f.mu.Unlock()
|
||||
if answer != nil {
|
||||
code, out := answer(body)
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write([]byte(out))
|
||||
return
|
||||
}
|
||||
switch method {
|
||||
case "sendMessage":
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":` + jsonInt(id) + `,"chat":{"id":42,"type":"private"}}}`))
|
||||
case "getMe":
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"mesh_bot"}}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":true}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
was := API
|
||||
API = srv.URL
|
||||
t.Cleanup(func() { API = was })
|
||||
return f
|
||||
}
|
||||
|
||||
func jsonInt(n int64) string { b, _ := json.Marshal(n); return string(b) }
|
||||
|
||||
type memStore struct {
|
||||
offset int64
|
||||
placed map[string]Placed
|
||||
shown map[[2]int64]string
|
||||
}
|
||||
|
||||
func (m *memStore) Shown(chat, id int64) (string, error) { return m.shown[[2]int64{chat, id}], nil }
|
||||
func (m *memStore) Show(chat, id int64, digest string) error {
|
||||
if m.shown == nil {
|
||||
m.shown = map[[2]int64]string{}
|
||||
}
|
||||
m.shown[[2]int64{chat, id}] = digest
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) Offset() (int64, error) { return m.offset, nil }
|
||||
func (m *memStore) SetOffset(n int64) error { m.offset = n; return nil }
|
||||
func (m *memStore) Place(h string, p Placed) error {
|
||||
if m.placed == nil {
|
||||
m.placed = map[string]Placed{}
|
||||
}
|
||||
m.placed[h] = p
|
||||
return nil
|
||||
}
|
||||
func (m *memStore) Placed(h string) (*Placed, error) {
|
||||
p, ok := m.placed[h]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
type said struct {
|
||||
mu sync.Mutex
|
||||
by map[string][]json.RawMessage
|
||||
proved []json.RawMessage
|
||||
}
|
||||
|
||||
func (s *said) of(subject string) []json.RawMessage {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.by[subject]
|
||||
}
|
||||
|
||||
func newChannel(t *testing.T, token string) (*Channel, *fakeAPI, *said, *memStore) {
|
||||
t.Helper()
|
||||
f := newAPI(t)
|
||||
file := filepath.Join(t.TempDir(), "telegram-token")
|
||||
if token != "" {
|
||||
if err := os.WriteFile(file, []byte(token+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s := &said{by: map[string][]json.RawMessage{}}
|
||||
st := &memStore{}
|
||||
now := time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
|
||||
c := &Channel{Bot: &Bot{TokenFile: file}, Store: st, Now: func() time.Time { return now }, Logf: t.Logf,
|
||||
Sleep: func(time.Duration) {},
|
||||
Publish: func(subject string, body any, id string) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
s.mu.Lock()
|
||||
s.by[subject] = append(s.by[subject], raw)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
},
|
||||
Prove: func(subject string, body any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(body)
|
||||
s.mu.Lock()
|
||||
s.proved = append(s.proved, raw)
|
||||
s.mu.Unlock()
|
||||
if strings.Contains(string(raw), `"code":"123456"`) {
|
||||
return json.RawMessage(`{"accepted":true,"words":"Linked."}`), nil
|
||||
}
|
||||
return json.RawMessage(`{"accepted":false,"words":"That code is not right."}`), nil
|
||||
}}
|
||||
c.init()
|
||||
return c, f, s, st
|
||||
}
|
||||
|
||||
func work(verb string, m asks.Message) (string, json.RawMessage) {
|
||||
raw, _ := json.Marshal(m)
|
||||
return asks.ChannelSubject(verb, Kind), raw
|
||||
}
|
||||
|
||||
func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T) {
|
||||
c, f, _, st := newChannel(t, goodToken)
|
||||
subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Delivery of hq held", Body: "Needs you: release it.",
|
||||
To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "0123456789abcdef0123456789abcdef"}}})
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := f.of("sendMessage")
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("sent %d", len(sent))
|
||||
}
|
||||
b := sent[0].Body
|
||||
kb, _ := json.Marshal(b["reply_markup"])
|
||||
if b["chat_id"] != float64(42) || b["text"] != "Delivery of hq held\nNeeds you: release it." || b["disable_notification"] != false ||
|
||||
string(kb) != `{"inline_keyboard":[[{"callback_data":"0123456789abcdef0123456789abcdef","text":"Release"}]]}` {
|
||||
t.Errorf("sent %v %s", b, kb)
|
||||
}
|
||||
if _, deprecated := b["disable_web_page_preview"]; deprecated {
|
||||
t.Error("the deprecated preview flag is still sent (D7)")
|
||||
}
|
||||
if st.placed["m1"].ID == 0 {
|
||||
t.Error("where it is was not kept")
|
||||
}
|
||||
subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "Resolved: hq works again", To: "42"})
|
||||
_ = c.Take(subject, body)
|
||||
if f.of("sendMessage")[1].Body["disable_notification"] != true {
|
||||
t.Error("a clearing rang (D4)")
|
||||
}
|
||||
subject, body = work(asks.Send, asks.Message{Handle: "m3", Title: "Urgent: ace silent", Urgent: true, To: "42"})
|
||||
_ = c.Take(subject, body)
|
||||
if f.of("sendMessage")[2].Body["disable_notification"] != false {
|
||||
t.Error("an urgent message was silent (D4)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
|
||||
c, f, _, _ := newChannel(t, goodToken)
|
||||
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "t", Body: strings.Repeat("x", 5000), To: "42"})
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := f.of("sendMessage")[0].Body["text"].(string)
|
||||
if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) {
|
||||
t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) {
|
||||
c, f, s, st := newChannel(t, goodToken)
|
||||
st.Place("m1", Placed{Chat: 42, ID: 7})
|
||||
f.answer["editMessageText"] = func(map[string]any) (int, string) {
|
||||
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message is not modified"}`
|
||||
}
|
||||
subject, body := work(asks.Edit, asks.Message{Handle: "m1", Title: "same", To: "42"})
|
||||
if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 0 || len(s.of(asks.IntakeSubject(asks.FailedWhat, Kind))) != 0 {
|
||||
t.Fatalf("an identical edit was not a success (D6): %v", err)
|
||||
}
|
||||
f.answer["editMessageText"] = func(map[string]any) (int, string) {
|
||||
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message to edit not found"}`
|
||||
}
|
||||
_ = c.Take(subject, body)
|
||||
if len(f.of("sendMessage")) != 1 {
|
||||
t.Error("an edit of a message gone was not sent again")
|
||||
}
|
||||
if kb, _ := json.Marshal(f.of("editMessageText")[0].Body["reply_markup"]); string(kb) != `{"inline_keyboard":[]}` {
|
||||
t.Errorf("an edit without buttons left them: %s", kb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusalIsPermanentARateLimitIsWaitedOutAndNeitherCarriesTheToken(t *testing.T) {
|
||||
c, f, s, _ := newChannel(t, goodToken)
|
||||
tries := 0
|
||||
var slept []time.Duration
|
||||
c.Sleep = func(d time.Duration) { slept = append(slept, d) }
|
||||
f.answer["sendMessage"] = func(map[string]any) (int, string) {
|
||||
tries++
|
||||
if tries == 1 {
|
||||
return 429, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":7}}`
|
||||
}
|
||||
return 403, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user ` + goodToken + `"}`
|
||||
}
|
||||
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "x", To: "42"})
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(slept) != 1 || slept[0] != 7*time.Second || tries != 2 {
|
||||
t.Errorf("the rate limit was not waited out as told (D5): slept %v, tries %d", slept, tries)
|
||||
}
|
||||
failed := s.of(asks.IntakeSubject(asks.FailedWhat, Kind))
|
||||
if len(failed) != 1 || !strings.Contains(string(failed[0]), `"permanent":true`) || strings.Contains(string(failed[0]), goodToken) {
|
||||
t.Fatalf("the refusal: %s (D6)", failed)
|
||||
}
|
||||
// No linked account and nothing to reply to: said as failed for good, never retried.
|
||||
subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "x"})
|
||||
_ = c.Take(subject, body)
|
||||
if f := s.of(asks.IntakeSubject(asks.FailedWhat, Kind)); len(f) != 2 || !strings.Contains(string(f[1]), "no account is linked") {
|
||||
t.Errorf("%s", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATapIsAcknowledgedAtOnceAndSaidWithItsAuthenticatedSender(t *testing.T) {
|
||||
c, f, s, _ := newChannel(t, goodToken)
|
||||
shown := showQuestion(t, c, f)
|
||||
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
||||
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Release it?\nThe delivery is held."}}})
|
||||
if len(f.of("answerCallbackQuery")) != 1 {
|
||||
t.Error("the tap was not acknowledged")
|
||||
}
|
||||
got := s.of(asks.IntakeSubject(asks.Chosen, Kind))
|
||||
var ch asks.Choice
|
||||
if len(got) != 1 || json.Unmarshal(got[0], &ch) != nil || ch.Ticket != "tkt" ||
|
||||
!reflect.DeepEqual(ch.Sender, asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}) {
|
||||
t.Fatalf("said %s", got)
|
||||
}
|
||||
// In a group, or from another bot, nothing is said: it is nobody's own chat with the bot.
|
||||
c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42},
|
||||
Message: &Msg{Chat: Chat{ID: -100, Type: "group"}}}})
|
||||
c.Handle(Update{UpdateID: 3, Callback: &Callback{ID: "cb3", Data: "tkt", From: User{ID: 9, IsBot: true},
|
||||
Message: &Msg{Chat: Chat{ID: 9, Type: "private"}}}})
|
||||
if len(s.of(asks.IntakeSubject(asks.Chosen, Kind))) != 1 {
|
||||
t.Error("a tap outside the account's own chat was said")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAsksALinkAndACodeIsAProofDeletedFromTheChat(t *testing.T) {
|
||||
c, f, s, _ := newChannel(t, goodToken)
|
||||
jo := &User{ID: 42, FirstName: "Jo", Username: "jo"}
|
||||
c.Handle(Update{UpdateID: 10, Message: &Msg{MessageID: 5, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "/start"}})
|
||||
var l asks.LinkAsked
|
||||
got := s.of(asks.IntakeSubject(asks.Link, Kind))
|
||||
if len(got) != 1 || json.Unmarshal(got[0], &l) != nil || l.Sender.Identity != "42" || l.Sender.Display != "Jo (@jo)" {
|
||||
t.Fatalf("link %s", got)
|
||||
}
|
||||
// The router's answer to the link goes to the chat it came from, though nobody is linked yet.
|
||||
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "Send the code shown on your desk", Reply: l.ID})
|
||||
if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 1 || f.of("sendMessage")[0].Body["chat_id"] != float64(42) {
|
||||
t.Fatalf("the reply did not reach the chat: %v %v", err, f.of("sendMessage"))
|
||||
}
|
||||
c.Handle(Update{UpdateID: 11, Message: &Msg{MessageID: 6, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "123 456"}})
|
||||
if len(s.proved) != 1 || !strings.Contains(string(s.proved[0]), `"code":"123456"`) {
|
||||
t.Fatalf("the code was not asked as a proof: %s", s.proved)
|
||||
}
|
||||
for subject, events := range s.by {
|
||||
for _, e := range events {
|
||||
if strings.Contains(string(e), "123456") {
|
||||
t.Errorf("the code was said on %s", subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
if d := f.of("deleteMessage"); len(d) != 1 || d[0].Body["message_id"] != float64(6) {
|
||||
t.Error("the code was not deleted from the chat")
|
||||
}
|
||||
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != "Linked." {
|
||||
t.Errorf("the router's answer was not said: %v", last[len(last)-1].Body)
|
||||
}
|
||||
c.Handle(Update{UpdateID: 12, Message: &Msg{MessageID: 7, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "release it"}})
|
||||
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != FixedLine {
|
||||
t.Error("other words were not answered with the fixed line")
|
||||
}
|
||||
if c.Store.(*memStore).offset != 0 {
|
||||
t.Error("the offset moved without a poll")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandingSaysWhatIsMissingAndNeverTheToken(t *testing.T) {
|
||||
for token, want := range map[string]string{
|
||||
"": "no bot token",
|
||||
"a-random-value-x": "not a bot token",
|
||||
"987654321:" + strings.Repeat("Z", 35): "does not work",
|
||||
} {
|
||||
c, _, _, _ := newChannel(t, token)
|
||||
s := c.Standing()
|
||||
if s.Ready || !strings.Contains(s.Why, want) || (token != "" && strings.Contains(s.Why, token)) {
|
||||
t.Errorf("%q: %+v", token, s)
|
||||
}
|
||||
}
|
||||
c, _, said, _ := newChannel(t, goodToken)
|
||||
if s := c.Standing(); !s.Ready || !s.EditsSilently {
|
||||
t.Errorf("%+v", s)
|
||||
}
|
||||
c.SayStanding()
|
||||
c.SayStanding()
|
||||
if n := len(said.of(asks.IntakeSubject(asks.StandingWhat, Kind))); n != 1 {
|
||||
t.Errorf("standing said %d times without a change", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollTakesTheUpdatesAndKeepsTheOffsetAndSaysAConflict(t *testing.T) {
|
||||
c, f, s, st := newChannel(t, goodToken)
|
||||
f.answer["getUpdates"] = func(map[string]any) (int, string) {
|
||||
return 200, `{"ok":true,"result":[{"update_id":41,"message":{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"text":"/start"}}]}`
|
||||
}
|
||||
if err := c.Poll(time.Second); err != nil || st.offset != 42 || len(s.of(asks.IntakeSubject(asks.Link, Kind))) != 1 {
|
||||
t.Fatalf("%v offset %d", err, st.offset)
|
||||
}
|
||||
f.answer["getUpdates"] = func(map[string]any) (int, string) {
|
||||
return 409, `{"ok":false,"error_code":409,"description":"Conflict: terminated by other getUpdates request"}`
|
||||
}
|
||||
if err := c.Poll(time.Second); err == nil || !strings.Contains(err.Error(), "another program takes this bot's updates") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if s := c.Standing(); s.Ready {
|
||||
t.Error("ready while the updates do not arrive")
|
||||
}
|
||||
}
|
||||
|
||||
// showQuestion shows one question with a button to the linked account, and answers its message's id.
|
||||
func showQuestion(t *testing.T, c *Channel, f *fakeAPI) int64 {
|
||||
t.Helper()
|
||||
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Release it?", Body: "The delivery is held.",
|
||||
To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "tkt"}}})
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := f.of("sendMessage")
|
||||
if len(sent) == 0 {
|
||||
t.Fatal("the question was not sent")
|
||||
}
|
||||
f.mu.Lock()
|
||||
id := f.next
|
||||
f.mu.Unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
// A tap is said only on a message showing exactly what this channel put there (the review of 2026-10-09): a
|
||||
// message whose words were changed by another holder of the token — "Release" under other words — or one
|
||||
// this channel never showed, is refused on the phone and never reaches the router.
|
||||
func TestATapOnAMessageShowingOtherWordsIsRefused(t *testing.T) {
|
||||
c, f, s, st := newChannel(t, goodToken)
|
||||
shown := showQuestion(t, c, f)
|
||||
tap := func(id string, msg int64, words string) {
|
||||
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: id, Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
||||
Message: &Msg{MessageID: msg, Chat: Chat{ID: 42, Type: "private"}, Text: words}}})
|
||||
}
|
||||
tap("cb-other", shown, "Silence it for a week?\nNothing changes.")
|
||||
tap("cb-unknown", shown+100, "Release it?\nThe delivery is held.")
|
||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 0 {
|
||||
t.Fatalf("a tap on other words was said: %s", got)
|
||||
}
|
||||
answers := f.of("answerCallbackQuery")
|
||||
if len(answers) != 2 || !strings.Contains(answers[0].Body["text"].(string), "not") {
|
||||
t.Errorf("the phone was not told: %v", answers)
|
||||
}
|
||||
// The same words, as Telegram gives them back (its line endings, its outer spaces), are taken.
|
||||
tap("cb-same", shown, " Release it?\r\nThe delivery is held. ")
|
||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
||||
t.Fatalf("the tap on the words shown was not said: %s", got)
|
||||
}
|
||||
// An edit moves what was shown: the old words are then refused.
|
||||
subject, body := work(asks.Edit, asks.Message{Handle: "q1", Title: "Released", Body: "by you", To: "42"})
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tap("cb-old", shown, "Release it?\nThe delivery is held.")
|
||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
||||
t.Errorf("a tap on words since replaced was said")
|
||||
}
|
||||
if d, _ := st.Shown(42, shown); d == "" {
|
||||
t.Error("what an edit showed was not kept")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// telegram: a channel of the operator's conversation, kind `telegram` of the `channel` and `intake` benches
|
||||
// (novox/hq ADR 0234, ADR 0259). A Go bundle the node's runtime launches as this module's own account, never
|
||||
// root and never the operator's: the bot token and this channel's state are that account's alone. stdout is
|
||||
// the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// Worker is the work queue of this kind, as the controller names it.
|
||||
const Worker = "SEAT_CHANNEL_TELEGRAM_worker"
|
||||
|
||||
// busStore keeps the update offset and where each message is in this module's state on the bus.
|
||||
type busStore struct{}
|
||||
|
||||
func (busStore) Offset() (int64, error) {
|
||||
e, err := stdio.State("offset").Get("updates")
|
||||
if err != nil || e == nil {
|
||||
return 0, err
|
||||
}
|
||||
var o struct {
|
||||
Next int64 `json:"next"`
|
||||
}
|
||||
return o.Next, json.Unmarshal(e.Value, &o)
|
||||
}
|
||||
func (busStore) SetOffset(next int64) error {
|
||||
_, err := stdio.State("offset").Put("updates", map[string]int64{"next": next})
|
||||
return err
|
||||
}
|
||||
func (busStore) Placed(handle string) (*Placed, error) {
|
||||
e, err := stdio.State("messages").Get(handle)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
var p Placed
|
||||
return &p, json.Unmarshal(e.Value, &p)
|
||||
}
|
||||
func (busStore) Place(handle string, p Placed) error {
|
||||
_, err := stdio.State("messages").Put(handle, p)
|
||||
return err
|
||||
}
|
||||
|
||||
// shownKey is where what a message shows is kept, beside the router's handles in the same state.
|
||||
func shownKey(chat, id int64) string { return fmt.Sprintf("shown-words.%d.%d", chat, id) }
|
||||
|
||||
func (busStore) Shown(chat, id int64) (string, error) {
|
||||
e, err := stdio.State("messages").Get(shownKey(chat, id))
|
||||
if err != nil || e == nil {
|
||||
return "", err
|
||||
}
|
||||
var w struct {
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
return w.Digest, json.Unmarshal(e.Value, &w)
|
||||
}
|
||||
func (busStore) Show(chat, id int64, digest string) error {
|
||||
_, err := stdio.State("messages").Put(shownKey(chat, id), map[string]string{"digest": digest})
|
||||
return err
|
||||
}
|
||||
|
||||
func main() {
|
||||
c := &Channel{
|
||||
Bot: &Bot{TokenFile: os.Getenv("MESH_TELEGRAM_TOKEN_FILE"), Client: &http.Client{Timeout: PollWait + 15*time.Second}},
|
||||
Store: busStore{},
|
||||
Publish: func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) },
|
||||
Prove: stdio.SeatProve,
|
||||
Logf: logf,
|
||||
}
|
||||
c.init()
|
||||
go run(c)
|
||||
tools := []stdio.Tool{{Name: "telegram_status",
|
||||
Description: "Whether this channel can reach the operator on Telegram, and why not: whether the bot token works " +
|
||||
"(never the token), the bot's name, when updates last arrived, the last delivery and the last error, how " +
|
||||
"many it delivered and failed since it started, and the rules it keeps. Linking an account as the " +
|
||||
"operator is the router's (messenger_identities).",
|
||||
Run: func(map[string]any) (any, error) { return c.Status(), nil }}}
|
||||
if err := stdio.Serve("", tools); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run takes this kind's work, long-polls the updates and says its standing — each retried, each failure said.
|
||||
func run(c *Channel) {
|
||||
time.Sleep(500 * time.Millisecond) // Serve first: the bus is reached through it
|
||||
go func() {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.SeatTake(Worker, func(w stdio.Work) error { return c.Take(w.Subject, w.Body) })
|
||||
if err == nil {
|
||||
logf("[telegram] taking the router's messages for this kind")
|
||||
return
|
||||
}
|
||||
logf("[telegram] not taking messages yet (%v); asking again in %s", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
c.SayStanding()
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
}()
|
||||
said := ""
|
||||
for {
|
||||
if _, err := c.Bot.token(); err != nil {
|
||||
if err.Error() != said {
|
||||
logf("[telegram] %v", err)
|
||||
said = err.Error()
|
||||
}
|
||||
time.Sleep(time.Minute)
|
||||
continue
|
||||
}
|
||||
if err := c.Poll(PollWait); err != nil {
|
||||
if err.Error() != said {
|
||||
logf("[telegram] the updates do not arrive: %v", err)
|
||||
said = err.Error()
|
||||
}
|
||||
c.SayStanding()
|
||||
time.Sleep(10 * time.Second)
|
||||
continue
|
||||
}
|
||||
said = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The manifest keeps the bot token and this channel's state from every agent (novox/hq ADR 0259 §8): the
|
||||
// bundle runs as the module's own account, which owns its state directory and its secret; it claims one kind
|
||||
// on both benches, with what it promises; and it names nothing of one installation.
|
||||
func TestItRunsAsItsOwnAccountWhichAloneHoldsTheToken(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Claims []struct {
|
||||
Name, Scope, Kind string
|
||||
Capabilities []string
|
||||
}
|
||||
Own map[string]string `json:"own-secrets"`
|
||||
Owner string `json:"secrets-owner"`
|
||||
RunsAs string `json:"runs-as"`
|
||||
Resources []map[string]any
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
Env map[string]string
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Claims) != 2 || m.Claims[0].Name != "channel" || m.Claims[0].Kind != Kind || m.Claims[1].Name != "intake" ||
|
||||
m.Claims[1].Kind != Kind {
|
||||
t.Fatalf("claims %+v", m.Claims)
|
||||
}
|
||||
promises := strings.Join(m.Claims[0].Capabilities, " ")
|
||||
for _, c := range []string{"reaches-away", "choice", "verified-sender", "edit", "max-length:4096"} {
|
||||
if !strings.Contains(promises, c) {
|
||||
t.Errorf("it does not promise %s", c)
|
||||
}
|
||||
}
|
||||
env := m.Build.Artifacts[0].Env
|
||||
if m.RunsAs != "telegram" || env["MESH_TELEGRAM_TOKEN_FILE"] != m.Own["telegram-token"] || m.Owner != "telegram" ||
|
||||
m.Own["broker"] == "" {
|
||||
t.Fatalf("runs as %q, env %v, secrets %v, owner %q", m.RunsAs, env, m.Own, m.Owner)
|
||||
}
|
||||
var account, state bool
|
||||
for _, r := range m.Resources {
|
||||
switch r["type"] {
|
||||
case "user":
|
||||
account = r["name"] == "telegram" && r["shell"] == "/usr/bin/nologin"
|
||||
case "directory":
|
||||
state = r["id"] == "state" && r["owner"] == "telegram" && r["mode"] == "0700"
|
||||
}
|
||||
}
|
||||
if !account || !state {
|
||||
t.Errorf("the account %v, its state %v", account, state)
|
||||
}
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox", "api.telegram"} {
|
||||
if strings.Contains(strings.ToLower(string(raw)), never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module telegram
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.10
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"module": "telegram",
|
||||
"version": "1",
|
||||
"slug": "tgrm",
|
||||
"runs-as": "telegram",
|
||||
"claims": [
|
||||
{
|
||||
"name": "channel",
|
||||
"scope": "mesh",
|
||||
"kind": "telegram",
|
||||
"capabilities": [
|
||||
"deliver",
|
||||
"reaches-away",
|
||||
"silent",
|
||||
"edit",
|
||||
"max-length:4096",
|
||||
"choice",
|
||||
"verified-sender",
|
||||
"exact-render",
|
||||
"code-factor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "intake",
|
||||
"scope": "mesh",
|
||||
"kind": "telegram"
|
||||
}
|
||||
],
|
||||
"state": [
|
||||
"offset",
|
||||
{
|
||||
"name": "messages",
|
||||
"ttl-seconds": 2592000
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker",
|
||||
"telegram-token": "${dir:state}/telegram-token"
|
||||
},
|
||||
"secrets-owner": "telegram",
|
||||
"tools": [
|
||||
"telegram_status"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "telegram",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/telegram"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "telegram",
|
||||
"place": "."
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/telegram",
|
||||
"binary": "telegram",
|
||||
"loads": [
|
||||
"telegram"
|
||||
],
|
||||
"env": {
|
||||
"MESH_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user