The operator stopped believing the mesh's notifications: one problem stood twice on the desk (a reminder beside the message it followed, an escalation then a reminder 31 s later), a unit failing again at each apply was said again 22 times, and a question's buttons outlived it on a reminder the ask's end never reached. - messenger: a reminder or escalation is said in place of the problem's message (Replace under its handle), alone, never as a digest line; a problem first said in a digest gets its own message next time. A reminder counts from when it was last said. A clearing is said once it stays cleared 2 min (10 min once it came back); back before that, the message continues without a word. Cleared before ever said and back: said. An ask's expiry is said with its day. - desk-channel: a send or show under a handle already shown replaces its notification by the notifier's replace id. - telegram: the same deletes the earlier message and sends the new one, ringing as its kind rings; one it cannot delete loses its buttons.
346 lines
10 KiB
Go
346 lines
10 KiB
Go
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)
|
|
}
|
|
|
|
// Replace says a message again under the handle of one shown before: the channel takes the earlier one away
|
|
// and shows this one as a new message is shown — ringing as its kind rings (issue 373). An edit is quiet.
|
|
func (c *SeatChannel) Replace(handle string, m Message) error {
|
|
if handle == "" {
|
|
return errors.New("no message on record to replace")
|
|
}
|
|
return 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) Replace(handle string, m Message) error {
|
|
if handle == "" {
|
|
return errors.New("no message on record to replace")
|
|
}
|
|
s := &SeatChannel{Bench: c.Bench, Publish: c.Publish}
|
|
return 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
|
|
}
|