Files
mesh-catalog/modules/messenger/cmd/messenger/seatchannel.go
T
jochen 3345ff9317 One message per problem: a reminder replaces, a blip says nothing (issues 373 and 369)
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.
2026-10-10 14:22:42 +02:00

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
}