The review of 2026-10-09: the router read the absence of an agent-root condition as a pass, and its minute hung on a break-glass file, taking the work it already held. An approval now rests on the controller's verb root-free, asked live for the router's machine and the channel's, failing closed on every other answer, and a stale word on the kinds promises no verified sender. Break-glass runs in the minute, says on every channel what root did, and ends the router's own open questions. An ask offering an approval is answered only where it could be approved; a message says who asks, what each answer does and what expiry does. The router's own asks bind the account they are about; a link is checked against one per kind as it is made; an unlinked account does not come back by a code within a day; a fresh link is unlinked from the desk; no link begins while the desk cannot show its code, and a code is marked secret on every path. Links are bounded per account in the router's state; a warrant's message id cannot be foreseen. Moves to mesh-sdk 76902998 (canonical digests).
617 lines
21 KiB
Go
617 lines
21 KiB
Go
// 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
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "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...) }
|
|
|
|
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 {
|
|
// 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"`
|
|
}
|
|
|
|
// zoneOf is the location a time-zone setting names, or this machine's own when it names none or one
|
|
// that does not exist (said, never guessed silently).
|
|
func zoneOf(name string) (*time.Location, error) {
|
|
if name == "" {
|
|
return time.Local, nil
|
|
}
|
|
loc, err := time.LoadLocation(name)
|
|
if err != nil {
|
|
return time.Local, fmt.Errorf("the time-zone %q is not one this machine knows; times are said in its own zone", name)
|
|
}
|
|
return loc, nil
|
|
}
|
|
|
|
func readSettings(path string) (Settings, error) {
|
|
var s Settings
|
|
if path == "" {
|
|
return s, errors.New("started without a settings file")
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
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)
|
|
}
|
|
if tz, ok := loose["time-zone"].(string); ok {
|
|
s.TimeZone = strings.TrimSpace(tz)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// stateStore keeps the open messages in the module's declared state `open`, and the recent sends
|
|
// under one key of `sent` (ADR 0201).
|
|
type stateStore struct{}
|
|
|
|
// kvKey is a condition key as a bucket key: only the characters a key may carry.
|
|
func kvKey(key string) string {
|
|
var b strings.Builder
|
|
for _, r := range key {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '=':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('_')
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), ".")
|
|
}
|
|
|
|
func (stateStore) Put(r Record) error {
|
|
_, err := stdio.State("open").Put(kvKey(r.Key), r)
|
|
return err
|
|
}
|
|
func (stateStore) Delete(key string) error { return stdio.State("open").Delete(kvKey(key)) }
|
|
func (stateStore) All() ([]Record, error) {
|
|
keys, err := stdio.State("open").Keys()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []Record
|
|
for _, k := range keys {
|
|
e, err := stdio.State("open").Get(k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if e == nil {
|
|
continue
|
|
}
|
|
var r Record
|
|
if err := json.Unmarshal(e.Value, &r); err != nil {
|
|
logf("[messenger] the open message kept as %s cannot be read (%v); left as it is", k, err)
|
|
continue
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, nil
|
|
}
|
|
func (stateStore) PutRecent(s []Sent) error {
|
|
_, err := stdio.State("sent").Put("recent", s)
|
|
return err
|
|
}
|
|
func (stateStore) Recent() ([]Sent, error) {
|
|
e, err := stdio.State("sent").Get("recent")
|
|
if err != nil || e == nil {
|
|
return nil, err
|
|
}
|
|
var s []Sent
|
|
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
|
|
now string
|
|
}
|
|
|
|
func (l *listening) set(s string) { l.mu.Lock(); l.now = s; l.mu.Unlock() }
|
|
func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.now }
|
|
|
|
func main() {
|
|
settingsFile := os.Getenv("MESH_MESSENGER_SETTINGS")
|
|
var said sync.Mutex
|
|
lastSaid, zoneSaid := "", ""
|
|
settings := func() Settings {
|
|
s, err := readSettings(settingsFile)
|
|
said.Lock()
|
|
defer said.Unlock()
|
|
if err != nil && err.Error() != lastSaid {
|
|
logf("[messenger] settings cannot be read: %v", err)
|
|
}
|
|
lastSaid = ""
|
|
if err != nil {
|
|
lastSaid = err.Error()
|
|
}
|
|
return s
|
|
}
|
|
bench := NewBench(time.Now)
|
|
publish := func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) }
|
|
h := &Holder{
|
|
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()
|
|
defer said.Unlock()
|
|
if err != nil && err.Error() != zoneSaid {
|
|
logf("[messenger] %v", err)
|
|
}
|
|
zoneSaid = ""
|
|
if err != nil {
|
|
zoneSaid = err.Error()
|
|
}
|
|
return loc
|
|
},
|
|
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)
|
|
if err := stdio.Serve("", tools(h, l)); err != nil {
|
|
logf("%v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// run reads back the state, listens for condition events, and keeps time — each retried, and each
|
|
// failure said, never given up on quietly.
|
|
func run(h *Holder, l *listening) {
|
|
time.Sleep(500 * time.Millisecond) // Serve first: the state is reached through it
|
|
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
|
err := h.Load()
|
|
if err == nil {
|
|
break
|
|
}
|
|
logf("[messenger] cannot read back its open messages yet (%v); asking again in %s", err, wait)
|
|
time.Sleep(wait)
|
|
}
|
|
for _, ch := range h.channels() {
|
|
if err := ch.Ready(); err != nil {
|
|
logf("[messenger] %s cannot send: %v", ch.Name(), err)
|
|
}
|
|
}
|
|
// The state first: what is open now is read from the controller before any event is taken, so
|
|
// what the stream replays is history (novox/hq issue 271). Not reachable yet, it is asked for
|
|
// again in the loop below, and old events meanwhile are kept as state, never said.
|
|
h.Start()
|
|
syncFromController(h)
|
|
go func() {
|
|
tick := time.NewTicker(5 * time.Second)
|
|
defer tick.Stop()
|
|
lastTick, lastAsk := time.Now(), time.Now()
|
|
for now := range tick.C {
|
|
h.Flush()
|
|
if now.Sub(lastTick) >= time.Minute {
|
|
lastTick = now
|
|
h.Tick()
|
|
}
|
|
if h.WantsSync() && now.Sub(lastAsk) >= time.Minute {
|
|
lastAsk = now
|
|
syncFromController(h)
|
|
}
|
|
}
|
|
}()
|
|
handle := func(e stdio.Envelope) error {
|
|
event, ok := eventOf(e.Key)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
c, err := DecodeCondition(event, e.Body)
|
|
if err != nil {
|
|
h.Unreadable(e.Key, err)
|
|
return nil
|
|
}
|
|
h.Condition(event, c)
|
|
return nil
|
|
}
|
|
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 {
|
|
l.set("listening")
|
|
logf("[messenger] listening for the controller's condition events")
|
|
return
|
|
}
|
|
l.set("not yet: " + err.Error())
|
|
logf("[messenger] not hearing condition events yet (%v); asking again in %s", err, wait)
|
|
time.Sleep(wait)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
}
|
|
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})
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
|
if err := stdio.Subscribe(asks.IntakeSeat+".#", handle); err == nil {
|
|
logf("[messenger] listening for what the channels say")
|
|
break
|
|
} else {
|
|
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()
|
|
type answer struct {
|
|
raw json.RawMessage
|
|
err error
|
|
}
|
|
done := make(chan answer, 1)
|
|
go func() {
|
|
raw, err := stdio.Ask("seat:"+ControllerSeat+".conditions", map[string]any{})
|
|
done <- answer{raw, err}
|
|
}()
|
|
var a answer
|
|
select {
|
|
case a = <-done:
|
|
case <-time.After(30 * time.Second):
|
|
a.err = errors.New("no answer in 30 s")
|
|
}
|
|
if a.err != nil {
|
|
h.SyncFailed(a.err)
|
|
return
|
|
}
|
|
open, err := DecodeOpen(a.raw)
|
|
if err != nil {
|
|
h.SyncFailed(err)
|
|
return
|
|
}
|
|
h.Sync(open, asked)
|
|
}
|
|
|
|
func str(description string) map[string]any {
|
|
return map[string]any{"type": "string", "description": description}
|
|
}
|
|
|
|
func strArg(a map[string]any, k string) string { s, _ := a[k].(string); return strings.TrimSpace(s) }
|
|
|
|
func limitArg(a map[string]any, def int) int {
|
|
if v, ok := a["limit"].(float64); ok && v >= 1 {
|
|
return min(int(v), KeptSends)
|
|
}
|
|
return def
|
|
}
|
|
|
|
func tools(h *Holder, l *listening) []stdio.Tool {
|
|
return []stdio.Tool{
|
|
{Name: "operator-channel.open",
|
|
Description: "What is open now: every message the operator was sent about something still wrong, urgent first, " +
|
|
"oldest first — its key, severity, summary, since when, the channels it went to, whether it is silenced, " +
|
|
"reminded, held (for its burst window, the desktop's rhythm or the cap), refused, not sent yet, or " +
|
|
"never said (known from the state or an old event: history, not news).",
|
|
Run: func(map[string]any) (any, error) { return h.Open(), nil }},
|
|
{Name: "operator-channel.history",
|
|
Description: "What was said to the operator lately, newest first — each send, digest, edit and failure with its " +
|
|
"channel, key and outcome — and every message refused for carrying an address, a path or a secret.",
|
|
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many of each (default 50)"}},
|
|
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 50)), nil }},
|
|
{Name: "operator-channel.notify",
|
|
Description: "Tell the operator something, as a module that uses the seat: a key (the same key is the same " +
|
|
"message), urgent or warning, one line in the mesh's words, and what it is about; and best, in plain " +
|
|
"words, a headline, an explanation and the line said when it is over. clear says it is over. " +
|
|
"Roles and words only: an address, a path or a secret is refused. Deduplicated, capped and routed " +
|
|
"like the controller's conditions.",
|
|
Input: map[string]any{
|
|
"key": str("what makes it the same message the next time, e.g. backup.ace.failed"),
|
|
"severity": map[string]any{"type": "string", "enum": []string{Urgent, Warning}},
|
|
"summary": str("one line in the mesh's words"),
|
|
"subject": str("what it is about: a machine's role, a module, a plan"),
|
|
"headline": str("a few plain words the operator reads as the title, e.g. backup failed on ace: no ids, keys or commands"),
|
|
"explanation": str("one or two plain sentences: what happened, what it means for the operator, and whether " +
|
|
"they need to act"),
|
|
"resolved": str("the one short line said when it is over, e.g. backups run again on ace"),
|
|
"clear": map[string]any{"type": "boolean", "description": "it is over: the message is edited to say so"},
|
|
},
|
|
Run: func(a map[string]any) (any, error) {
|
|
clear, _ := a["clear"].(bool)
|
|
return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear,
|
|
strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved"))
|
|
}},
|
|
{Name: "messenger_status",
|
|
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) {
|
|
kind := strArg(a, "kind")
|
|
if kind == "" {
|
|
return nil, errorf("kind is required")
|
|
}
|
|
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 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": 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 }},
|
|
{Name: "messenger_check",
|
|
Description: "Would these words be allowed to leave the mesh? Runs the content rule — no address, path or " +
|
|
"secret — and answers allowed, or the class of what it carried. Sends nothing.",
|
|
Input: map[string]any{"text": str("the words to judge")},
|
|
Run: func(a map[string]any) (any, error) {
|
|
if r, ok := Check(strArg(a, "text")); !ok {
|
|
return map[string]any{"allowed": false, "class": r.Class, "carried": r.What}, nil
|
|
}
|
|
return map[string]any{"allowed": true}, nil
|
|
}},
|
|
}
|
|
}
|