Make the messenger the router: it holds every ask, judges each answer and issues the warrant its asker acts on, and keeps no channel's client (hq ADR 0259)
This commit is contained in:
+56
-46
@@ -1,20 +1,44 @@
|
||||
# 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 on 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.
|
||||
Every link and unlink is said on every channel. `messenger_unlink` unlinks.
|
||||
|
||||
## What is sent, and when
|
||||
|
||||
@@ -49,18 +73,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 +87,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 +106,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 +121,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,79 @@ 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) {
|
||||
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)
|
||||
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 +144,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 +186,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 +210,39 @@ 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"
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,298 +2,592 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The review of 2026-10-08 (novox/hq ADR 0258): a desk click performs only an acknowledgement, and an
|
||||
// answer's token is a one-time ticket bound to its condition and its machine.
|
||||
// The router of novox/hq ADR 0259: it holds every ask, judges each answer and issues a warrant; it
|
||||
// performs nothing. These tests hold it to every rule of the record's §4, §5 and §7.
|
||||
|
||||
// answering is a holder with the failed-units condition shown on the desktop of g14, and the calls the
|
||||
// holder makes recorded.
|
||||
func answering(t *testing.T, actions ...Action) (*Holder, *fakeChannel, *clock, *[]string, Condition) {
|
||||
t.Helper()
|
||||
h, _, dt, c, _ := newHolder(t)
|
||||
h.Telegram = nil
|
||||
asked := &[]string{}
|
||||
h.Ask = func(address string, body any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(body)
|
||||
*asked = append(*asked, address+" "+string(raw))
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
e := examples[3]
|
||||
if len(actions) > 0 {
|
||||
e.actions = actions
|
||||
}
|
||||
k, err := DecodeCondition(EventRaised, e.event(c.now(), false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.Condition(EventRaised, k)
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 1 {
|
||||
t.Fatalf("desktop sends %d", len(dt.sends))
|
||||
}
|
||||
return h, dt, c, asked, k
|
||||
// memAsks is the router's state in memory, with the compare-and-set the bus gives.
|
||||
type memAsks struct {
|
||||
asks map[string]AskRecord
|
||||
revs map[string]uint64
|
||||
ids map[string]Identity
|
||||
links map[string]PendingLink
|
||||
rev uint64
|
||||
}
|
||||
|
||||
func tokenOf(t *testing.T, m Message, label string) string {
|
||||
func newMemAsks() *memAsks {
|
||||
return &memAsks{asks: map[string]AskRecord{}, revs: map[string]uint64{}, ids: map[string]Identity{},
|
||||
links: map[string]PendingLink{}}
|
||||
}
|
||||
|
||||
func clone(r AskRecord) AskRecord {
|
||||
raw, _ := json.Marshal(r)
|
||||
var out AskRecord
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *memAsks) GetAsk(k string) (*AskRecord, uint64, error) {
|
||||
r, ok := m.asks[k]
|
||||
if !ok {
|
||||
return nil, 0, nil
|
||||
}
|
||||
c := clone(r)
|
||||
return &c, m.revs[k], nil
|
||||
}
|
||||
func (m *memAsks) CreateAsk(k string, r AskRecord) (uint64, error) {
|
||||
if _, ok := m.asks[k]; ok {
|
||||
return 0, ErrChanged
|
||||
}
|
||||
m.rev++
|
||||
m.asks[k], m.revs[k] = clone(r), m.rev
|
||||
return m.rev, nil
|
||||
}
|
||||
func (m *memAsks) UpdateAsk(k string, r AskRecord, rev uint64) (uint64, error) {
|
||||
if m.revs[k] != rev {
|
||||
return 0, ErrChanged
|
||||
}
|
||||
m.rev++
|
||||
m.asks[k], m.revs[k] = clone(r), m.rev
|
||||
return m.rev, nil
|
||||
}
|
||||
func (m *memAsks) AskKeys() ([]string, error) {
|
||||
var out []string
|
||||
for k := range m.asks {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *memAsks) GetIdentity(k string) (*Identity, error) {
|
||||
id, ok := m.ids[k]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
func (m *memAsks) PutIdentity(k string, id Identity) error { m.ids[k] = id; return nil }
|
||||
func (m *memAsks) DeleteIdentity(k string) error { delete(m.ids, k); return nil }
|
||||
func (m *memAsks) IdentityKeys() ([]string, error) {
|
||||
var out []string
|
||||
for k := range m.ids {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *memAsks) GetLink(kind string) (*PendingLink, error) {
|
||||
l, ok := m.links[kind]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
func (m *memAsks) PutLink(kind string, l PendingLink) error { m.links[kind] = l; return nil }
|
||||
func (m *memAsks) DeleteLink(kind string) error { delete(m.links, kind); return nil }
|
||||
|
||||
// told is what the router published to askers, by subject.
|
||||
type told struct{ by map[string][]asks.Warrant }
|
||||
|
||||
func (w *told) of(asker string) []asks.Warrant { return w.by[asks.DecidedSubject(asker)] }
|
||||
|
||||
// newRouter is a router with two kinds: telegram, which reaches the operator away and verifies its sender,
|
||||
// and the desk, which does neither — as the controller would issue them from the claims.
|
||||
func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memAsks, *told) {
|
||||
t.Helper()
|
||||
h, tg, dt, c, _ := newHolder(t)
|
||||
store := newMemAsks()
|
||||
w := &told{by: map[string][]asks.Warrant{}}
|
||||
bench := NewBench(c.now)
|
||||
bench.SetKinds([]KindInfo{
|
||||
{Kind: "telegram", Module: "telegram", Capabilities: []string{"deliver", "reaches-away", "choice", "edit", "verified-sender"}},
|
||||
{Kind: "desktop", Module: "desk-channel", Capabilities: []string{"deliver", "choice", "edit"}},
|
||||
})
|
||||
bench.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
bench.Stand("desktop", Standing{Ready: true, At: c.now()})
|
||||
h.Bench, h.Asks = bench, store
|
||||
h.Zone = func() *time.Location { return time.UTC }
|
||||
h.Publish = func(subject string, body any, id string) error {
|
||||
if wt, ok := body.(asks.Warrant); ok {
|
||||
w.by[subject] = append(w.by[subject], wt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return h, tg, dt, c, store, w
|
||||
}
|
||||
|
||||
// stand keeps the channels' word fresh as the clock moves.
|
||||
func stand(h *Holder, c *clock) {
|
||||
h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
h.Bench.Stand("desktop", Standing{Ready: true, At: c.now()})
|
||||
}
|
||||
|
||||
func heldAsk(c *clock, id string) asks.Ask {
|
||||
return asks.Ask{ID: id, Headline: "Delivery of hq held for 36 hours",
|
||||
Explanation: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours.",
|
||||
Who: asks.Operator, Expires: c.now().Add(2 * time.Hour), OnExpiry: "the delivery stays held",
|
||||
Options: []asks.Option{
|
||||
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: asks.Approve},
|
||||
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: asks.Approve},
|
||||
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said of it for a week", Level: asks.Acknowledge},
|
||||
}}
|
||||
}
|
||||
|
||||
func submit(t *testing.T, h *Holder, asker string, a asks.Ask) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(a)
|
||||
if err := h.TakeAsk("ask", asker, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func link(h *Holder, store *memAsks, c *clock, identity string, at time.Time) {
|
||||
store.ids[identityKey("telegram", identity)] = Identity{Kind: "telegram", Identity: identity, Display: "Jo",
|
||||
Linked: at, ApproveFrom: at.Add(LinkedApproveAfter)}
|
||||
}
|
||||
|
||||
func choiceOf(ticket, identity string, at time.Time) asks.Choice {
|
||||
return asks.Choice{ID: "c-" + ticket + "-" + identity, Ticket: ticket, At: at,
|
||||
Sender: asks.Sender{Identity: identity, Display: "Jo", Authenticated: true}}
|
||||
}
|
||||
|
||||
func ticketFor(m Message, label string) string {
|
||||
for _, a := range m.Actions {
|
||||
if a.Label == label {
|
||||
return a.Token
|
||||
}
|
||||
}
|
||||
t.Fatalf("%q is not offered: %+v", label, m.Actions)
|
||||
return ""
|
||||
}
|
||||
|
||||
// **Only an acknowledgement is offered or performed.** Release, Stop, Start and Restart are refused by the
|
||||
// allow-list: never offered, and a token for one (forged into the state) is refused without a call.
|
||||
func TestOnlyAnAcknowledgementIsAnAnswer(t *testing.T) {
|
||||
release := Action{Label: "Release", Verb: "mesh-delivery.release", Arguments: map[string]string{"id": "x", "why": ""}}
|
||||
restart := Action{Label: "Restart", Verb: "node-service-manager.restart", Machine: "g14",
|
||||
Arguments: map[string]string{"unit": "x.service"}}
|
||||
start := Action{Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": "p", "why": ""}}
|
||||
sneaky := Action{Label: "Silence", Verb: "mesh-controller.conditions", Arguments: map[string]string{"silence": "k", "stop": "p"}}
|
||||
silence := Action{Label: "Silence for a week", Verb: "mesh-controller.conditions",
|
||||
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": "", "cause": "operator-answer"}}
|
||||
for _, a := range []Action{release, restart, start, sneaky} {
|
||||
if answerable(a) {
|
||||
t.Errorf("%s is answerable", a.Label)
|
||||
func labelsOf(m Message) string {
|
||||
var out []string
|
||||
for _, a := range m.Actions {
|
||||
out = append(out, a.Label)
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func TestAnAskIsOfferedOnEachKindAsFarAsItCanCarryIt(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
a := heldAsk(c, "d-1")
|
||||
a.Urgent = true
|
||||
submit(t, h, "mesh-delivery", a)
|
||||
settle(h, c)
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 {
|
||||
t.Fatalf("telegram %d, desk %d", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
if got := labelsOf(tg.sends[0]); got != "Release, Stop, Silence for a week" {
|
||||
t.Errorf("telegram offers %q", got)
|
||||
}
|
||||
// The desk proves nothing about who clicked: acknowledgements only, and the words say where the rest go.
|
||||
if got := labelsOf(dt.sends[0]); got != "Silence for a week" {
|
||||
t.Errorf("the desk offers %q", got)
|
||||
}
|
||||
if !strings.Contains(dt.sends[0].Body, "Release or Stop: answer on Telegram, or from the mesh MCP server.") {
|
||||
t.Errorf("the desk does not say where to answer: %q", dt.sends[0].Body)
|
||||
}
|
||||
if rec := store.asks["mesh-delivery.d-1"]; rec.State != StateOpen || len(rec.Tickets) != 4 {
|
||||
t.Errorf("recorded %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) {
|
||||
h, tg, _, c, _, _ := newRouter(t)
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
c.pass(BurstWindow)
|
||||
h.Flush()
|
||||
if len(tg.sends) == 0 {
|
||||
t.Fatal("nothing was said")
|
||||
}
|
||||
m := tg.sends[0]
|
||||
if got := labelsOf(m); got != "Silence for a week" || !strings.Contains(m.Body, "Release or Stop: from the mesh MCP server.") {
|
||||
t.Errorf("telegram without a link offers %q and says %q", got, m.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
|
||||
h, tg, dt, 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")
|
||||
out, err := h.Chosen("telegram", choiceOf(release, "42", c.now()))
|
||||
if err != nil || out != "chosen" {
|
||||
t.Fatalf("%s %v", out, err)
|
||||
}
|
||||
got := w.of("mesh-delivery")
|
||||
if len(got) != 1 || got[0].Outcome != asks.OutcomeChosen || got[0].Option != "release" || got[0].Channel != "telegram" ||
|
||||
got[0].Says() != "the operator, via telegram (user id verified), chose Release" || strings.Join(got[0].Proofs, ",") != "P1" {
|
||||
t.Fatalf("the warrant: %+v", got)
|
||||
}
|
||||
if _, err := got[0].For("mesh-delivery", heldAsk(c, "d-1")); err != nil {
|
||||
t.Errorf("the asker would not act on it: %v", err)
|
||||
}
|
||||
if rec := store.asks["mesh-delivery.d-1"]; rec.State != string(asks.OutcomeChosen) || !rec.Told || !rec.Tickets[release].Used {
|
||||
t.Errorf("the record: %+v", rec)
|
||||
}
|
||||
// Every copy says how it ended, its buttons gone.
|
||||
for _, ch := range []*fakeChannel{tg, dt} {
|
||||
var edited Message
|
||||
for _, m := range ch.edits {
|
||||
edited = m
|
||||
}
|
||||
if len(edited.Actions) != 0 || !strings.Contains(edited.Body, "Release: chosen at") {
|
||||
t.Errorf("%s still offers %q: %q", ch.name, labelsOf(edited), edited.Body)
|
||||
}
|
||||
}
|
||||
if !answerable(silence) {
|
||||
t.Error("silence is not answerable")
|
||||
// One warrant: the same answer again, and another answer after it, are refused and said.
|
||||
if out, _ := h.Chosen("telegram", choiceOf(release, "42", c.now())); out != "chosen" {
|
||||
t.Errorf("a redelivery of the same answer was acted on again: %s", out)
|
||||
}
|
||||
h, dt, _, asked, _ := answering(t, release, restart, start, silence)
|
||||
m := dt.sends[0]
|
||||
var labels []string
|
||||
for _, a := range m.Actions {
|
||||
labels = append(labels, a.Label)
|
||||
again := choiceOf(release, "42", c.now())
|
||||
again.ID = "another"
|
||||
if out, err := h.Chosen("telegram", again); err == nil || out != "refused" {
|
||||
t.Errorf("a used ticket was taken: %s", out)
|
||||
}
|
||||
if strings.Join(labels, ",") != "Silence for a week,Details" {
|
||||
t.Fatalf("offered %v", labels)
|
||||
stop := ticketFor(tg.sends[0], "Stop")
|
||||
if out, err := h.Chosen("telegram", choiceOf(stop, "42", c.now())); err == nil || !strings.Contains(err.Error(), "already answered") {
|
||||
t.Errorf("a second answer stood: %s %v", out, err)
|
||||
}
|
||||
// A token for Release, put in the state by hand: refused, nothing called, and said on the desk.
|
||||
h.mu.Lock()
|
||||
r := h.open["machine.shanks.units"]
|
||||
r.Tokens["forged"] = Grant{Index: 0, Issued: h.Now()}
|
||||
h.mu.Unlock()
|
||||
before := len(dt.sends)
|
||||
if out, err := h.Chosen("desktop", "g14", "forged", "e1"); err == nil || out != "refused" || len(*asked) != 0 {
|
||||
t.Fatalf("a release was performed from a click: %q %v %v", out, err, *asked)
|
||||
if len(w.of("mesh-delivery")) != 1 {
|
||||
t.Errorf("more than one warrant: %+v", w.of("mesh-delivery"))
|
||||
}
|
||||
if len(dt.sends) != before+1 || dt.sends[len(dt.sends)-1].Title != "That answer was not taken" {
|
||||
t.Errorf("the refusal was not said where the click was: %+v", dt.sends[len(dt.sends)-1])
|
||||
if len(w.of("mesh-controller")) != 0 {
|
||||
t.Error("another asker heard the warrant")
|
||||
}
|
||||
}
|
||||
|
||||
// **At most four answers**, Details included.
|
||||
func TestANotificationOffersAtMostFourAnswers(t *testing.T) {
|
||||
var many []Action
|
||||
for i := 0; i < 6; i++ {
|
||||
many = append(many, Action{Label: fmt.Sprintf("Silence %d", i), Verb: "mesh-controller.conditions",
|
||||
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}})
|
||||
}
|
||||
_, dt, _, _, _ := answering(t, many...)
|
||||
if n := len(dt.sends[0].Actions); n != MostActions || dt.sends[0].Actions[n-1].Label != DetailsLabel {
|
||||
t.Fatalf("offered %+v", dt.sends[0].Actions)
|
||||
func TestEveryAnswerThatIsNotTheOperatorsIsRefusedAndSaid(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
kind, identity string
|
||||
authenticated bool
|
||||
linkedAgo time.Duration
|
||||
label string
|
||||
pass time.Duration
|
||||
want string
|
||||
}{
|
||||
"an account not linked": {"telegram", "666", true, 2 * time.Hour, "Release", 0, "not linked as the operator"},
|
||||
"a sender the service did not say": {"telegram", "42", false, 2 * time.Hour, "Release", 0, "did not authenticate"},
|
||||
"a link younger than an hour": {"telegram", "42", true, 59 * time.Minute, "Release", 0, "can approve from"},
|
||||
"an ask that expired": {"telegram", "42", true, 2 * time.Hour, "Release", 3 * time.Hour, "expired"},
|
||||
"a ticket shown on another kind": {"desktop", "g14", true, 2 * time.Hour, "Release", 0, "not offered here"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
h, tg, _, c, store, w := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-tc.linkedAgo))
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
ticket := ticketFor(tg.sends[0], tc.label)
|
||||
if ticket == "" {
|
||||
t.Fatalf("no %s offered: %q", tc.label, labelsOf(tg.sends[0]))
|
||||
}
|
||||
c.pass(tc.pass)
|
||||
ch := choiceOf(ticket, tc.identity, c.now())
|
||||
ch.Sender.Authenticated = tc.authenticated
|
||||
before := len(tg.sends)
|
||||
out, err := h.Chosen(tc.kind, ch)
|
||||
if err == nil || out != "refused" || !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 tc.kind == "telegram" && (len(tg.sends) != before+1 || tg.sends[len(tg.sends)-1].Title != "That answer was not taken") {
|
||||
t.Errorf("the refusal was not said on the channel: %+v", tg.sends[before:])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **A token is used once, an event acted on once**: the same token twice is refused the second time; the
|
||||
// same event delivered twice is acted on once and answers what came of it.
|
||||
func TestATokenIsUsedOnceAndAnEventActedOnOnce(t *testing.T) {
|
||||
h, dt, _, asked, _ := answering(t)
|
||||
tok := tokenOf(t, dt.sends[0], "Silence for a week")
|
||||
if out, err := h.Chosen("desktop", "g14", tok, "e1"); err != nil || out != "done" || len(*asked) != 1 {
|
||||
t.Fatalf("%q %v %v", out, err, *asked)
|
||||
}
|
||||
if out, err := h.Chosen("desktop", "g14", tok, "e1"); err != nil || out != "done" || len(*asked) != 1 {
|
||||
t.Fatalf("a redelivered event was acted on again: %q %v %v", out, err, *asked)
|
||||
}
|
||||
if out, err := h.Chosen("desktop", "g14", tok, "e2"); err == nil || out != "refused" || len(*asked) != 1 {
|
||||
t.Fatalf("the same token was taken twice: %q %v %v", out, err, *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// **A token is bound to its machine and its condition's life**: from another machine, for a condition
|
||||
// cleared or silenced, or past its day, it is refused.
|
||||
func TestATokenIsRefusedElsewhereAfterItsConditionAndPastItsDay(t *testing.T) {
|
||||
h, dt, c, asked, k := answering(t)
|
||||
tok := tokenOf(t, dt.sends[0], "Silence for a week")
|
||||
if out, _ := h.Chosen("desktop", "shanks", tok, "e1"); out != "refused" {
|
||||
t.Errorf("an answer from a machine it was not shown on was taken: %q", out)
|
||||
}
|
||||
if out, _ := h.Chosen("desktop", "g14", "0123456789abcdef01234567", "e2"); out != "refused" {
|
||||
t.Errorf("an unknown token was taken: %q", out)
|
||||
}
|
||||
c.pass(TokenFor + time.Minute)
|
||||
if out, _ := h.Chosen("desktop", "g14", tok, "e3"); out != "refused" {
|
||||
t.Errorf("a token past its day was taken: %q", out)
|
||||
}
|
||||
|
||||
h, dt, c, asked, k = answering(t)
|
||||
tok = tokenOf(t, dt.sends[0], "Silence for a week")
|
||||
k.SilencedTill = c.now().Add(time.Hour)
|
||||
h.Condition(EventChanged, k)
|
||||
if out, _ := h.Chosen("desktop", "g14", tok, "e4"); out != "refused" {
|
||||
t.Errorf("an answer for a silenced condition was taken: %q", out)
|
||||
}
|
||||
|
||||
h, dt, c, asked, k = answering(t)
|
||||
tok = tokenOf(t, dt.sends[0], "Silence for a week")
|
||||
h.Condition(EventCleared, Condition{Key: k.Key})
|
||||
if out, _ := h.Chosen("desktop", "g14", tok, "e5"); out != "refused" || len(*asked) != 0 {
|
||||
t.Errorf("an answer for a cleared condition was taken: %q %v", out, *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// **A refused verb is said where it was chosen**: the mesh's refusal edits the notification.
|
||||
func TestAVerbTheMeshRefusesIsSaid(t *testing.T) {
|
||||
h, dt, _, _, _ := answering(t)
|
||||
h.Ask = func(string, any) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"isError":true,"content":[{"text":"silence is refused: no such condition"}]}`), nil
|
||||
}
|
||||
tok := tokenOf(t, dt.sends[0], "Silence for a week")
|
||||
out, err := h.Chosen("desktop", "g14", tok, "e1")
|
||||
if err == nil || !strings.HasPrefix(out, "failed") {
|
||||
t.Fatalf("%q %v", out, err)
|
||||
}
|
||||
if m := dt.edits["g14=1"]; !strings.HasPrefix(m.Title, "Could not silence for a week") ||
|
||||
!strings.Contains(m.Body, "no such condition") {
|
||||
t.Errorf("the refusal was not said: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// **A words-only change to "Needs you" after a quiet time is said**, through the event and through a
|
||||
// reading of the state; and a quiet warning is said once after a day.
|
||||
func TestAQuietWarningIsSaidWhenItsWordsNeedTheOperator(t *testing.T) {
|
||||
quiet := examples[0]
|
||||
needing := quiet
|
||||
needing.needs = "check that mesh-delivery runs."
|
||||
needing.explanation = "Needs you: check that mesh-delivery runs. It has waited."
|
||||
|
||||
// Through the event.
|
||||
h, tg, dt, c, _ := newHolder(t)
|
||||
k, _ := DecodeCondition(EventRaised, quiet.event(c.now(), false))
|
||||
h.Condition(EventRaised, k)
|
||||
runFor(h, c, 2*time.Hour)
|
||||
if len(dt.sends)+len(tg.sends) != 0 {
|
||||
t.Fatal("said while it needed nothing")
|
||||
}
|
||||
k, _ = DecodeCondition(EventChanged, needing.event(c.now(), false))
|
||||
h.Condition(EventChanged, k)
|
||||
func TestALinkApprovesAfterAnHourAndAcknowledgesAtOnce(t *testing.T) {
|
||||
h, tg, _, c, store, w := newRouter(t)
|
||||
link(h, store, c, "42", c.now())
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: check that mesh-delivery runs.") {
|
||||
t.Fatalf("event: desktop %+v", dt.sends)
|
||||
release, silence := ticketFor(tg.sends[0], "Release"), ticketFor(tg.sends[0], "Silence for a week")
|
||||
c.pass(59*time.Minute - BurstWindow)
|
||||
if out, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err == nil || out != "refused" {
|
||||
t.Fatalf("approved at 59 minutes: %s", out)
|
||||
}
|
||||
c.pass(time.Minute)
|
||||
stand(h, c)
|
||||
late := choiceOf(release, "42", c.now().Add(time.Second))
|
||||
late.ID = "at-sixty"
|
||||
if out, err := h.Chosen("telegram", late); err != nil || out != "chosen" {
|
||||
t.Fatalf("refused at 60 minutes: %s %v", out, err)
|
||||
}
|
||||
// And an acknowledgement is taken at once from a fresh link.
|
||||
h2, tg2, _, c2, store2, w2 := newRouter(t)
|
||||
link(h2, store2, c2, "42", c2.now())
|
||||
submit(t, h2, "mesh-delivery", heldAsk(c2, "d-2"))
|
||||
settle(h2, c2)
|
||||
silence = ticketFor(tg2.sends[0], "Silence for a week")
|
||||
if out, err := h2.Chosen("telegram", choiceOf(silence, "42", c2.now())); err != nil || out != "chosen" {
|
||||
t.Fatalf("an acknowledgement from a fresh link was refused: %s %v", out, err)
|
||||
}
|
||||
if got := w2.of("mesh-delivery"); len(got) != 1 || got[0].Level != asks.Acknowledge {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
_ = w
|
||||
}
|
||||
|
||||
// Through a reading of the state.
|
||||
h, tg, dt, c, _ = newHolder(t)
|
||||
k, _ = DecodeCondition(EventRaised, quiet.event(c.now(), false))
|
||||
h.Condition(EventRaised, k)
|
||||
runFor(h, c, 2*time.Hour)
|
||||
k, _ = DecodeCondition("conditions", needing.event(c.now(), false))
|
||||
k.At = time.Time{}
|
||||
h.Sync([]Condition{k}, c.now())
|
||||
func TestTheDeskAcknowledgesAndNeverApproves(t *testing.T) {
|
||||
h, _, dt, c, _, w := newRouter(t)
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Body, "Needs you: check that mesh-delivery runs.") {
|
||||
t.Fatalf("sync: desktop %+v", dt.sends)
|
||||
silence := ticketFor(dt.sends[0], "Silence for a week")
|
||||
ch := choiceOf(silence, "g14", c.now())
|
||||
ch.Sender.Authenticated = false
|
||||
if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" {
|
||||
t.Fatalf("%s %v", out, err)
|
||||
}
|
||||
got := w.of("mesh-delivery")
|
||||
if len(got) != 1 || got[0].By.Verified != "a desk click: whoever was at the operator's session on g14" || len(got[0].Proofs) != 0 {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
// An approve ticket forged onto the desk is refused: the kind cannot carry it.
|
||||
h2, tg2, _, c2, store2, _ := newRouter(t)
|
||||
link(h2, store2, c2, "42", c2.now().Add(-2*time.Hour))
|
||||
submit(t, h2, "mesh-delivery", heldAsk(c2, "d-1"))
|
||||
settle(h2, c2)
|
||||
rec := store2.asks["mesh-delivery.d-1"]
|
||||
release := ticketFor(tg2.sends[0], "Release")
|
||||
tk := rec.Tickets[release]
|
||||
tk.Kind = "desktop"
|
||||
rec.Tickets[release] = tk
|
||||
store2.asks["mesh-delivery.d-1"] = rec
|
||||
h2.keepAsk("mesh-delivery.d-1", rec, store2.revs["mesh-delivery.d-1"])
|
||||
if out, err := h2.Chosen("desktop", choiceOf(release, "g14", c2.now())); err == nil || out != "refused" ||
|
||||
!strings.Contains(err.Error(), "can only acknowledge") {
|
||||
t.Errorf("the desk approved: %s %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// After a day, once, even if it still needs nothing.
|
||||
h, tg, dt, c, _ = newHolder(t)
|
||||
c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC)
|
||||
k, _ = DecodeCondition(EventRaised, quiet.event(c.now(), false))
|
||||
h.Condition(EventRaised, k)
|
||||
for i := 0; i < 50; i++ {
|
||||
c.pass(time.Hour)
|
||||
h.Tick()
|
||||
settle(h, c)
|
||||
func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) {
|
||||
h, _, _, c, store, w := newRouter(t)
|
||||
bad := heldAsk(c, "d-1")
|
||||
bad.Options[0].Level = asks.Destroy
|
||||
submit(t, h, "mesh-delivery", bad)
|
||||
carrying := heldAsk(c, "d-2")
|
||||
carrying.Explanation = "Needs you: see /etc/passwd on the machine."
|
||||
submit(t, h, "mesh-delivery", carrying)
|
||||
for i := 3; i <= 6; i++ {
|
||||
a := heldAsk(c, "d-"+string(rune('0'+i)))
|
||||
a.About = "about-" + string(rune('0'+i))
|
||||
submit(t, h, "mesh-delivery", a)
|
||||
}
|
||||
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Still open after a day: ") ||
|
||||
!strings.HasPrefix(dt.sends[0].Body, "Nothing for you to do.") {
|
||||
t.Fatalf("after a day: desktop %+v", dt.sends)
|
||||
got := w.of("mesh-delivery")
|
||||
var refused []string
|
||||
for _, g := range got {
|
||||
if g.Outcome == asks.OutcomeRefused {
|
||||
refused = append(refused, g.Ask+": "+g.Words)
|
||||
}
|
||||
}
|
||||
all := strings.Join(refused, "\n")
|
||||
for _, want := range []string{"d-1: the ask is refused: an ask that destroys lasts at most", "d-2: its words carry",
|
||||
"d-6: its asker already has 3 open asks"} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("not refused: %q in\n%s", want, all)
|
||||
}
|
||||
}
|
||||
if _, kept := store.asks["mesh-delivery.d-1"]; kept {
|
||||
t.Error("a refused ask was recorded")
|
||||
}
|
||||
// No channel can carry anything: refused, never queued in silence.
|
||||
h2, _, _, c2, _, w2 := newRouter(t)
|
||||
h2.Bench.Stand("telegram", Standing{Ready: false, Why: "no bot token", At: c2.now()})
|
||||
h2.Bench.Stand("desktop", Standing{Ready: false, Why: "no session", At: c2.now()})
|
||||
submit(t, h2, "mesh-delivery", heldAsk(c2, "d-1"))
|
||||
if got := w2.of("mesh-delivery"); len(got) != 1 || !strings.Contains(got[0].Words, "no channel can carry any of its answers now") {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskExpiresAndNeverDefaults(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)
|
||||
c.pass(2 * time.Hour)
|
||||
stand(h, c)
|
||||
h.Tick()
|
||||
got := w.of("mesh-delivery")
|
||||
if len(got) != 1 || got[0].Outcome != asks.OutcomeExpired || got[0].Option != "" || !strings.Contains(got[0].Words, "the delivery stays held") {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
var edited Message
|
||||
for _, m := range tg.edits {
|
||||
edited = m
|
||||
}
|
||||
if len(edited.Actions) != 0 || !strings.Contains(edited.Body, "No longer answerable") {
|
||||
t.Errorf("the copy still offers answers: %+v", edited)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewerAskAboutTheSameThingReplacesTheOlderAndACancelEndsIt(t *testing.T) {
|
||||
h, _, _, c, store, w := newRouter(t)
|
||||
a := heldAsk(c, "d-1")
|
||||
a.About = "delivery.hq"
|
||||
submit(t, h, "mesh-delivery", a)
|
||||
b := heldAsk(c, "d-2")
|
||||
b.About = "delivery.hq"
|
||||
submit(t, h, "mesh-delivery", b)
|
||||
if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) {
|
||||
t.Errorf("the older ask was not replaced: %s", store.asks["mesh-delivery.d-1"].State)
|
||||
}
|
||||
if err := h.TakeAsk("cancel", "mesh-delivery", json.RawMessage(`{"id":"d-2"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := w.of("mesh-delivery")
|
||||
if len(got) != 2 || got[0].Outcome != asks.OutcomeReplaced || got[1].Outcome != asks.OutcomeCancelled {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
// Another asker cannot cancel it: its cancel names its own ask, under its own name.
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-3"))
|
||||
_ = h.TakeAsk("cancel", "mesh-controller", json.RawMessage(`{"id":"d-3"}`))
|
||||
if store.asks["mesh-delivery.d-3"].State != StateOpen {
|
||||
t.Error("another asker's cancel ended it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskAboutAConditionIsAnsweredOnItsMessage(t *testing.T) {
|
||||
h, tg, dt, c, store, w := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
k := cond("delivery.hq.held", Warning, "the delivery of hq is held")
|
||||
k.Raised = c.now()
|
||||
h.Condition(EventRaised, k)
|
||||
settle(h, c)
|
||||
a := heldAsk(c, "d-1")
|
||||
a.About = "delivery.hq.held"
|
||||
submit(t, h, "mesh-controller", a)
|
||||
settle(h, c)
|
||||
// Its first message was on the desk alone (a warning, the desk ready); the ask re-shows it with answers.
|
||||
var shown Message
|
||||
for _, m := range dt.edits {
|
||||
shown = m
|
||||
}
|
||||
if got := labelsOf(shown); got != "Silence for a week, Details" {
|
||||
t.Errorf("the desk's copy offers %q: %+v", got, shown)
|
||||
}
|
||||
if store.asks["mesh-controller.d-1"].Record != "delivery.hq.held" {
|
||||
t.Errorf("the ask is shown on %q", store.asks["mesh-controller.d-1"].Record)
|
||||
}
|
||||
silence := ticketFor(shown, "Silence for a week")
|
||||
if out, err := h.Chosen("desktop", choiceOf(silence, "g14", c.now())); err != nil || out != "chosen" {
|
||||
t.Fatalf("%s %v", out, err)
|
||||
}
|
||||
if got := w.of("mesh-controller"); len(got) != 1 || got[0].About != "delivery.hq.held" {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
_ = tg
|
||||
}
|
||||
|
||||
// **A notifier that started again is offered the open answers again**, with fresh tokens: the old ones
|
||||
// are refused, the new ones taken.
|
||||
func TestANotifierThatRestartedIsOfferedTheAnswersAgain(t *testing.T) {
|
||||
h, dt, _, asked, _ := answering(t)
|
||||
old := tokenOf(t, dt.sends[0], "Silence for a week")
|
||||
if n := h.Reoffer("g14"); n != 1 {
|
||||
t.Fatalf("offered again: %d", n)
|
||||
func TestAnAskerNotToldIsToldAgain(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)
|
||||
pub := h.Publish
|
||||
h.Publish = func(string, any, string) error { return errorf("the bus is away") }
|
||||
if out, _ := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Stop"), "42", c.now())); out != "chosen" {
|
||||
t.Fatal(out)
|
||||
}
|
||||
again := dt.edits["g14=1"]
|
||||
fresh := tokenOf(t, again, "Silence for a week")
|
||||
if fresh == old || again.Title != dt.sends[0].Title {
|
||||
t.Fatalf("not offered again: %+v", again)
|
||||
if store.asks["mesh-delivery.d-1"].Told {
|
||||
t.Fatal("told while the bus was away")
|
||||
}
|
||||
if out, _ := h.Chosen("desktop", "g14", old, "e1"); out != "refused" {
|
||||
t.Errorf("the lost answer's token still works: %q", out)
|
||||
}
|
||||
if out, _ := h.Chosen("desktop", "g14", fresh, "e2"); out != "done" || len(*asked) != 1 {
|
||||
t.Errorf("the fresh token: %q %v", out, *asked)
|
||||
}
|
||||
if n := h.Reoffer("shanks"); n != 0 {
|
||||
t.Errorf("offered on a machine it was never shown on: %d", n)
|
||||
h.Publish = pub
|
||||
c.pass(time.Minute)
|
||||
stand(h, c)
|
||||
h.Tick()
|
||||
if got := w.of("mesh-delivery"); len(got) != 1 || got[0].Option != "stop" || !store.asks["mesh-delivery.d-1"].Told {
|
||||
t.Errorf("not told again: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A silence answers only its own condition** (ADR 0258): an action silencing another key is not offered,
|
||||
// and a token for one is refused.
|
||||
func TestASilenceAnswersOnlyItsOwnCondition(t *testing.T) {
|
||||
other := Action{Label: "Silence another", Verb: "mesh-controller.conditions",
|
||||
Arguments: map[string]string{"silence": "machine.ace.silent", "for": "7d", "why": ""}}
|
||||
own := Action{Label: "Silence for a week", Verb: "mesh-controller.conditions",
|
||||
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": ""}}
|
||||
h, dt, _, asked, _ := answering(t, other, own)
|
||||
var labels []string
|
||||
for _, a := range dt.sends[0].Actions {
|
||||
labels = append(labels, a.Label)
|
||||
}
|
||||
if strings.Join(labels, ",") != "Silence for a week,Details" {
|
||||
t.Fatalf("offered %v", labels)
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.open["machine.shanks.units"].Tokens["forged"] = Grant{Index: 0, Issued: h.Now()}
|
||||
h.mu.Unlock()
|
||||
if out, _ := h.Chosen("desktop", "g14", "forged", "e1"); out != "refused" || len(*asked) != 0 {
|
||||
t.Fatalf("another condition was silenced: %q %v", out, *asked)
|
||||
func TestAnAnswerReplayedLongAfterIsNotActedOn(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)
|
||||
old := choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now().Add(-time.Hour))
|
||||
if out, _ := h.Chosen("telegram", old); out != "too old" || len(w.of("mesh-delivery")) != 0 {
|
||||
t.Errorf("%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// **A record kept by the build before ADR 0258 loads**: its tokens were token -> index; it is read with
|
||||
// them dropped, never skipped (the old tokens die, as they must).
|
||||
func TestARecordKeptByTheBuildBeforeLoads(t *testing.T) {
|
||||
old := `{"key":"machine.shanks.units","kind":"units","subject":"machine shanks","severity":"warning",` +
|
||||
`"summary":"s","headline":"3 failed services on shanks","origin":"condition","more":"m",` +
|
||||
`"raised":"2026-10-08T12:38:00Z","sent":{"desktop":"g14=7"},"count":1,` +
|
||||
`"actions":[{"label":"Release","verb":"mesh-delivery.release","arguments":{"id":"x","why":""}}],` +
|
||||
`"tokens":{"0123456789abcdef01234567":0,"89abcdef0123456789abcdef":-1}}`
|
||||
var r Record
|
||||
if err := json.Unmarshal([]byte(old), &r); err != nil {
|
||||
t.Fatalf("the old record does not load: %v", err)
|
||||
func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
asked := asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}
|
||||
h.Link("telegram", asked)
|
||||
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Link a Telegram account as you") {
|
||||
t.Fatalf("no code on the desk: %+v", dt.sends)
|
||||
}
|
||||
if r.Key != "machine.shanks.units" || len(r.Tokens) != 0 || r.Sent["desktop"] != "g14=7" {
|
||||
t.Fatalf("%+v", r)
|
||||
code := strings.ReplaceAll(strings.TrimSuffix(strings.SplitAfter(dt.sends[0].Body, "before ")[1][7:14], "."), " ", "")
|
||||
if last := tg.sends[len(tg.sends)-1]; last.Title != "Send the code shown on your desk" {
|
||||
t.Errorf("telegram was told %q", last.Title)
|
||||
}
|
||||
// Loaded into a holder, its old answer is refused and nothing is called.
|
||||
h, _, _, _, _ := newHolder(t)
|
||||
asked := 0
|
||||
h.Ask = func(string, any) (json.RawMessage, error) { asked++; return nil, nil }
|
||||
st := &memStore{recs: map[string]Record{r.Key: r}}
|
||||
h.Store = st
|
||||
if err := h.Load(); err != nil {
|
||||
if p := store.links["telegram"]; p.CodeHash == "" || strings.Contains(p.CodeHash, code) {
|
||||
t.Errorf("the code is kept in clear: %+v", p)
|
||||
}
|
||||
// Another account sending the code is refused; a wrong code is refused; the right one links.
|
||||
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code}); a.Accepted {
|
||||
t.Error("another account linked with the code")
|
||||
}
|
||||
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: "000000x"}); a.Accepted {
|
||||
t.Error("a wrong code linked")
|
||||
}
|
||||
a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"})
|
||||
if !a.Accepted || !strings.Contains(a.Words, "approve from 13:00") {
|
||||
t.Fatalf("the right code: %+v (code %q)", a, code)
|
||||
}
|
||||
id := store.ids[identityKey("telegram", "42")]
|
||||
if id.ApproveFrom.Sub(id.Linked) != time.Hour {
|
||||
t.Errorf("approves from %s, linked %s", id.ApproveFrom, id.Linked)
|
||||
}
|
||||
// Said on every channel.
|
||||
for _, ch := range []*fakeChannel{tg, dt} {
|
||||
if last := ch.sends[len(ch.sends)-1]; !strings.Contains(last.Title, "is now linked as you") {
|
||||
t.Errorf("%s was not told: %q", ch.name, last.Title)
|
||||
}
|
||||
}
|
||||
// A second account cannot link while one is.
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "666", Authenticated: true}})
|
||||
if _, pending := store.links["telegram"]; pending {
|
||||
t.Error("a second link was begun while one is linked")
|
||||
}
|
||||
// Unlinking is said everywhere, and the account's answers are refused from then.
|
||||
if _, err := h.Unlink("telegram"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, _ := h.Chosen("desktop", "g14", "0123456789abcdef01234567", "e1"); out != "refused" || asked != 0 {
|
||||
t.Fatalf("an old token was taken: %q %d", out, asked)
|
||||
if len(store.ids) != 0 || !strings.Contains(tg.sends[len(tg.sends)-1].Title, "No Telegram account is linked") {
|
||||
t.Errorf("not unlinked and said: %v %q", store.ids, tg.sends[len(tg.sends)-1].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALinkCodeExpiresAndFiveWrongTriesEndIt(t *testing.T) {
|
||||
h, _, _, c, store, _ := newRouter(t)
|
||||
jo := asks.Sender{Identity: "42", Authenticated: true}
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: jo})
|
||||
for i := 0; i < LinkTries; i++ {
|
||||
h.Prove("telegram", asks.Code{Sender: jo, Code: "nope"})
|
||||
}
|
||||
if _, pending := store.links["telegram"]; pending {
|
||||
t.Error("five wrong codes did not end the link")
|
||||
}
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: jo})
|
||||
c.pass(LinkCodeFor)
|
||||
if a := h.Prove("telegram", asks.Code{Sender: jo, Code: "123456"}); a.Accepted || !strings.Contains(a.Words, "expired") {
|
||||
t.Errorf("%+v", a)
|
||||
}
|
||||
// The desk cannot be linked: it proves nothing about who clicked.
|
||||
h.Link("desktop", asks.LinkAsked{ID: "l3", At: c.now(), Sender: asks.Sender{Identity: "g14"}})
|
||||
if _, pending := store.links["desktop"]; pending {
|
||||
t.Error("the desk began a link")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
package main
|
||||
|
||||
// Asks and warrants (novox/hq ADR 0259): the router holds every ask, judges each answer and issues a
|
||||
// warrant; the asker performs the option with its own grants. The router performs nothing.
|
||||
//
|
||||
// - **An ask arrives on the seat's work queue under its asker's name** (`accept.ask.<asker>`), which the
|
||||
// bus grants that asker alone; the name is read from the subject, never from the body. It is recorded
|
||||
// in the bucket `asks` under `<asker>.<id>` before the work is acknowledged, so a restart loses none.
|
||||
// - **It is refused at once, in words, on its asker's `decided` subject** when its words break a rule,
|
||||
// when no ready channel can carry any of its answers, or when its asker already has three open. An
|
||||
// option no channel can carry is named in the words, with where it can still be given.
|
||||
// - **Each option gets a one-time ticket per channel kind that can carry it**: a button carries only the
|
||||
// ticket. Bound to the ask, the option and the kind; dead when the ask closes or expires.
|
||||
// - **An answer is judged in order, refusing at the first failure and saying so on the channel**: the ticket
|
||||
// is known, unused and for this kind; the ask is open and unexpired; the sender is the operator's
|
||||
// linked account where the kind verifies its sender; the kind's capabilities, from the controller's
|
||||
// record of its claim, meet the option's level; for approve, the account was linked an hour ago or more.
|
||||
// Then the ask is closed by compare-and-set — a second answer loses — and the warrant published.
|
||||
// - **A warrant not yet heard by its asker is published again** every minute until the bus takes it.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// ErrChanged is a compare-and-set that lost: another writer was first.
|
||||
var ErrChanged = errors.New("written by another writer first")
|
||||
|
||||
// LinkedApproveAfter is how long a newly linked account waits before it can approve (decided by the
|
||||
// operator on 2026-10-08, until an authenticator-app code exists).
|
||||
const LinkedApproveAfter = time.Hour
|
||||
|
||||
// StateOpen is an ask not yet ended; every other state is the asks.Outcome it ended with.
|
||||
const StateOpen = "open"
|
||||
|
||||
// Ticket is one option's one-time ticket on one channel kind.
|
||||
type Ticket struct {
|
||||
Option string `json:"option"`
|
||||
Kind string `json:"kind"`
|
||||
Used bool `json:"used,omitempty"`
|
||||
}
|
||||
|
||||
// AskRecord is one ask as the router keeps it, under `<asker>.<id>` in the bucket `asks`. Its asker reads
|
||||
// it there under its own name.
|
||||
type AskRecord struct {
|
||||
Asker string `json:"asker"`
|
||||
Ask asks.Ask `json:"ask"`
|
||||
State string `json:"state"`
|
||||
Opened time.Time `json:"opened"`
|
||||
Closed time.Time `json:"closed,omitempty"`
|
||||
Tickets map[string]Ticket `json:"tickets,omitempty"`
|
||||
Warrant *asks.Warrant `json:"warrant,omitempty"`
|
||||
// Told: the warrant, or the end, was published to the asker.
|
||||
Told bool `json:"told,omitempty"`
|
||||
// Record is the message the ask is shown on: a condition's key, or its own.
|
||||
Record string `json:"record,omitempty"`
|
||||
}
|
||||
|
||||
// Identity is one account linked as the operator on a channel kind.
|
||||
type Identity struct {
|
||||
Kind string `json:"kind"`
|
||||
Identity string `json:"identity"`
|
||||
Display string `json:"display,omitempty"`
|
||||
Linked time.Time `json:"linked"`
|
||||
ApproveFrom time.Time `json:"approve-from"`
|
||||
}
|
||||
|
||||
// AskStore is the router's own state for asks and the operator's identities (ADR 0201).
|
||||
type AskStore interface {
|
||||
GetAsk(key string) (*AskRecord, uint64, error)
|
||||
CreateAsk(key string, r AskRecord) (uint64, error)
|
||||
UpdateAsk(key string, r AskRecord, revision uint64) (uint64, error)
|
||||
AskKeys() ([]string, error)
|
||||
GetIdentity(key string) (*Identity, error)
|
||||
PutIdentity(key string, id Identity) error
|
||||
DeleteIdentity(key string) error
|
||||
IdentityKeys() ([]string, error)
|
||||
GetLink(kind string) (*PendingLink, error)
|
||||
PutLink(kind string, l PendingLink) error
|
||||
DeleteLink(kind string) error
|
||||
}
|
||||
|
||||
type askEntry struct {
|
||||
rec AskRecord
|
||||
rev uint64
|
||||
}
|
||||
|
||||
func askKey(asker, id string) string { return asker + "." + id }
|
||||
|
||||
// loadAsks reads back every ask, and indexes the tickets of the open ones.
|
||||
func (h *Holder) loadAsks() error {
|
||||
if h.Asks == nil {
|
||||
return nil
|
||||
}
|
||||
keys, err := h.Asks.AskKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
rec, rev, err := h.Asks.GetAsk(k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec == nil {
|
||||
continue
|
||||
}
|
||||
h.keepAsk(k, *rec, rev)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Holder) keepAsk(key string, rec AskRecord, rev uint64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.asks == nil {
|
||||
h.asks = map[string]*askEntry{}
|
||||
h.tickets = map[string]string{}
|
||||
}
|
||||
h.asks[key] = &askEntry{rec: rec, rev: rev}
|
||||
// Kept after the ask ends, so a late answer is told why it was not taken.
|
||||
for t := range rec.Tickets {
|
||||
h.tickets[t] = key
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Holder) askOf(key string) *askEntry {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.asks == nil {
|
||||
return nil
|
||||
}
|
||||
return h.asks[key]
|
||||
}
|
||||
|
||||
// openAsk is the ask a record shows, when it is still open.
|
||||
func (h *Holder) openAsk(r *Record) *AskRecord {
|
||||
if r == nil || r.Ask == "" {
|
||||
return nil
|
||||
}
|
||||
e := h.askOf(r.Ask)
|
||||
if e == nil || e.rec.State != StateOpen {
|
||||
return nil
|
||||
}
|
||||
rec := e.rec
|
||||
return &rec
|
||||
}
|
||||
|
||||
// carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for
|
||||
// approve verifying their sender with an account of the operator's linked.
|
||||
func (h *Holder) carriers(level asks.Level) []string {
|
||||
if h.Bench == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, k := range h.Bench.Kinds() {
|
||||
if h.Bench.Ready(k.Kind) != nil || !h.Bench.Promises(k.Kind, "choice") {
|
||||
continue
|
||||
}
|
||||
switch level {
|
||||
case asks.Acknowledge:
|
||||
out = append(out, k.Kind)
|
||||
case asks.Approve:
|
||||
if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) {
|
||||
out = append(out, k.Kind)
|
||||
}
|
||||
}
|
||||
// Destroy needs a code, which no channel carries yet.
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TakeAsk takes one ask or cancel from the seat's queue. asker is the subject's last token, which the bus
|
||||
// grants that module alone. An error is returned only when the ask could not be recorded, so the queue
|
||||
// offers it again; a refusal is said to the asker and the work is taken.
|
||||
func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
h.mu.Unlock()
|
||||
if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) {
|
||||
h.Logf("[messenger] an ask from %q was taken and dropped: not a module's name", asker)
|
||||
return nil
|
||||
}
|
||||
if verb == "cancel" {
|
||||
var c struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(body, &c) != nil || !asks.UsableID(c.ID) {
|
||||
h.Logf("[messenger] a cancel from %s names no ask", asker)
|
||||
return nil
|
||||
}
|
||||
return h.closeAsk(askKey(asker, c.ID), asks.OutcomeCancelled, "its asker took it back", nil, "")
|
||||
}
|
||||
var a asks.Ask
|
||||
if err := json.Unmarshal(body, &a); err != nil {
|
||||
h.refuseAsk(asker, asks.Ask{ID: "unreadable"}, "it could not be read: "+err.Error())
|
||||
return nil
|
||||
}
|
||||
key := askKey(asker, a.ID)
|
||||
if h.askOf(key) != nil {
|
||||
return nil // a redelivery: already recorded
|
||||
}
|
||||
now := h.Now()
|
||||
if err := a.Check(now); err != nil {
|
||||
h.refuseAsk(asker, a, err.Error())
|
||||
return nil
|
||||
}
|
||||
words := []string{a.Headline, a.Explanation, a.OnExpiry}
|
||||
for _, o := range a.Options {
|
||||
words = append(words, o.Label, o.Does)
|
||||
}
|
||||
if refusal, ok := Check(strings.Join(words, "\n")); !ok {
|
||||
h.refuseAsk(asker, a, "its words carry "+refusal.String()+", which may not leave the mesh: move it into a reference or say it in words")
|
||||
return nil
|
||||
}
|
||||
open, replaced := 0, ""
|
||||
h.mu.Lock()
|
||||
for k, e := range h.asks {
|
||||
if e.rec.Asker != asker || e.rec.State != StateOpen {
|
||||
continue
|
||||
}
|
||||
if a.About != "" && e.rec.Ask.About == a.About {
|
||||
replaced = k
|
||||
continue
|
||||
}
|
||||
open++
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if open >= asks.MostOpen {
|
||||
h.refuseAsk(asker, a, fmt.Sprintf("its asker already has %d open asks", open))
|
||||
return nil
|
||||
}
|
||||
rec := AskRecord{Asker: asker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{}}
|
||||
for _, o := range a.Options {
|
||||
for _, kind := range h.carriers(o.Level) {
|
||||
rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: kind}
|
||||
}
|
||||
}
|
||||
if len(rec.Tickets) == 0 {
|
||||
h.refuseAsk(asker, a, "no channel can carry any of its answers now: "+h.whyNot())
|
||||
return nil
|
||||
}
|
||||
if replaced != "" {
|
||||
if err := h.closeAsk(replaced, asks.OutcomeReplaced, "a newer ask about the same thing replaced it", nil, ""); err != nil && !errors.Is(err, ErrChanged) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Shown on the condition it is about, when that is open here; otherwise as a message of its own.
|
||||
h.mu.Lock()
|
||||
target := h.open["ask."+key]
|
||||
if a.About != "" {
|
||||
if r := h.open[a.About]; r != nil && r.Cleared.IsZero() {
|
||||
target = r
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if target != nil {
|
||||
rec.Record = target.Key
|
||||
} else {
|
||||
rec.Record = "ask." + key
|
||||
}
|
||||
if h.Asks != nil {
|
||||
rev, err := h.Asks.CreateAsk(key, rec)
|
||||
if errors.Is(err, ErrChanged) {
|
||||
got, rev, err := h.Asks.GetAsk(key)
|
||||
if err != nil || got == nil {
|
||||
return fmt.Errorf("the ask %s is on record and cannot be read: %v", key, err)
|
||||
}
|
||||
h.keepAsk(key, *got, rev)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("the ask %s could not be recorded: %w", key, err)
|
||||
}
|
||||
h.keepAsk(key, rec, rev)
|
||||
} else {
|
||||
h.keepAsk(key, rec, 0)
|
||||
}
|
||||
h.Logf("[messenger] %s asks %q (%s), shown on %s", asker, a.Headline, a.ID, rec.Record)
|
||||
if target != nil {
|
||||
h.mu.Lock()
|
||||
target.Ask = key
|
||||
h.mu.Unlock()
|
||||
if !target.silenced(now) {
|
||||
h.offerAsk(target)
|
||||
}
|
||||
h.persist(target)
|
||||
return nil
|
||||
}
|
||||
severity := Warning
|
||||
if a.Urgent {
|
||||
severity = Urgent
|
||||
}
|
||||
r := Record{Key: "ask." + key, Kind: "ask", Subject: asker, Severity: severity, 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()
|
||||
h.deliver(&r, "raised")
|
||||
h.persist(&r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// askKinds are the kinds an open ask about a record is offered on.
|
||||
func (h *Holder) askKinds(r *Record) map[string]bool {
|
||||
rec := h.openAsk(r)
|
||||
if rec == nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, t := range rec.Tickets {
|
||||
out[t.Kind] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// offerAsk shows a record's new ask: edited in place where the record was shown, a message of its own on a
|
||||
// channel that carries the ask and has not shown the record.
|
||||
func (h *Holder) offerAsk(r *Record) {
|
||||
kinds := h.askKinds(r)
|
||||
h.mu.Lock()
|
||||
sent := map[string]string{}
|
||||
for k, v := range r.Sent {
|
||||
sent[k] = v
|
||||
}
|
||||
h.mu.Unlock()
|
||||
now := h.Now()
|
||||
for _, ch := range h.channels() {
|
||||
id, shown := sent[ch.Name()]
|
||||
switch {
|
||||
case shown && id != "" && ch.CanEdit():
|
||||
err := ch.Edit(id, h.sayOn(r, "asked", ch.Name()))
|
||||
h.noteChannel(ch.Name(), err)
|
||||
if err == nil {
|
||||
h.record(Sent{At: now, Channel: ch.Name(), Key: r.Key, What: "asked", Outcome: "edited"})
|
||||
continue
|
||||
}
|
||||
h.enqueue(ch.Name(), item{Key: r.Key, What: "asked", Severity: r.Severity, At: now})
|
||||
case kinds[ch.Name()] && ch.Ready() == nil:
|
||||
h.enqueue(ch.Name(), item{Key: r.Key, What: "asked", Severity: r.Severity, At: now})
|
||||
}
|
||||
}
|
||||
h.flush(now, false)
|
||||
}
|
||||
|
||||
// refuseAsk says to its asker that an ask was not taken, and why.
|
||||
func (h *Holder) refuseAsk(asker string, a asks.Ask, why string) {
|
||||
h.Logf("[messenger] refused an ask from %s (%s): %s", asker, a.ID, why)
|
||||
h.tell(asks.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: asks.OutcomeRefused, Words: why, At: h.Now()})
|
||||
}
|
||||
|
||||
// tell publishes a warrant, or an ask's end, to its asker alone, deduplicated by the ask.
|
||||
func (h *Holder) tell(w asks.Warrant) error {
|
||||
if h.Publish == nil {
|
||||
return errors.New("this router cannot reach the bus")
|
||||
}
|
||||
err := h.Publish(asks.DecidedSubject(w.Asker), w, askKey(w.Asker, w.Ask)+"."+string(w.Outcome))
|
||||
if err != nil {
|
||||
h.Logf("[messenger] could not tell %s how its ask %s ended (%v); told again in a minute", w.Asker, w.Ask, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// closeAsk ends an open ask by compare-and-set, tells its asker, and edits every copy. A second close
|
||||
// loses: ErrChanged when another outcome was first.
|
||||
func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *asks.Warrant, used string) error {
|
||||
e := h.askOf(key)
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
rec, rev := e.rec, e.rev
|
||||
if h.Asks != nil {
|
||||
got, r, err := h.Asks.GetAsk(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got != nil {
|
||||
rec, rev = *got, r
|
||||
}
|
||||
}
|
||||
if rec.State != StateOpen {
|
||||
h.keepAsk(key, rec, rev)
|
||||
return ErrChanged
|
||||
}
|
||||
now := h.Now()
|
||||
rec.State = string(outcome)
|
||||
rec.Closed = now
|
||||
if t, ok := rec.Tickets[used]; ok {
|
||||
t.Used = true
|
||||
rec.Tickets[used] = t
|
||||
}
|
||||
if w == nil {
|
||||
w = &asks.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: outcome, Words: words, At: now}
|
||||
}
|
||||
rec.Warrant = w
|
||||
if h.Asks != nil {
|
||||
newRev, err := h.Asks.UpdateAsk(key, rec, rev)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrChanged) {
|
||||
if got, r, gerr := h.Asks.GetAsk(key); gerr == nil && got != nil {
|
||||
h.keepAsk(key, *got, r)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
rev = newRev
|
||||
}
|
||||
h.keepAsk(key, rec, rev)
|
||||
if h.tell(*w) == nil {
|
||||
h.markTold(key)
|
||||
}
|
||||
h.showClosed(rec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// markTold records that the asker was told.
|
||||
func (h *Holder) markTold(key string) {
|
||||
e := h.askOf(key)
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
rec, rev := e.rec, e.rev
|
||||
rec.Told = true
|
||||
if h.Asks != nil {
|
||||
newRev, err := h.Asks.UpdateAsk(key, rec, rev)
|
||||
if err != nil {
|
||||
return // read again and told again by the sweep
|
||||
}
|
||||
rev = newRev
|
||||
}
|
||||
h.keepAsk(key, rec, rev)
|
||||
}
|
||||
|
||||
// showClosed edits every copy of an ask's message to say how it ended, its buttons gone.
|
||||
func (h *Holder) showClosed(rec AskRecord) {
|
||||
h.mu.Lock()
|
||||
r := h.open[rec.Record]
|
||||
h.mu.Unlock()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
switch {
|
||||
case rec.Warrant != nil && rec.Warrant.Outcome == asks.OutcomeChosen:
|
||||
r.Answered = rec.Warrant.Label + ": chosen at " + rec.Closed.In(h.zone()).Format("15:04") + " on " + Display(rec.Warrant.By.Kind)
|
||||
case rec.State == string(asks.OutcomeExpired):
|
||||
r.Answered = "No longer answerable: nobody answered in time. " + capital(rec.Ask.OnExpiry)
|
||||
case rec.State == string(asks.OutcomeReplaced):
|
||||
r.Answered = ""
|
||||
default:
|
||||
r.Answered = "No longer needed."
|
||||
}
|
||||
ownMessage := r.Origin == "ask"
|
||||
h.mu.Unlock()
|
||||
if r.told() {
|
||||
h.edit(r, "answered", false)
|
||||
}
|
||||
if ownMessage && rec.State != string(asks.OutcomeReplaced) {
|
||||
h.mu.Lock()
|
||||
r.Cleared = h.Now()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
h.persist(r)
|
||||
}
|
||||
|
||||
// sweepAsks ends asks past their expiry, and tells again an asker not yet told. Called every minute.
|
||||
func (h *Holder) sweepAsks(now time.Time) {
|
||||
h.mu.Lock()
|
||||
var expired, untold []string
|
||||
for k, e := range h.asks {
|
||||
switch {
|
||||
case e.rec.State == StateOpen && !now.Before(e.rec.Ask.Expires):
|
||||
expired = append(expired, k)
|
||||
case e.rec.State != StateOpen && !e.rec.Told && e.rec.Warrant != nil:
|
||||
untold = append(untold, k)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
sort.Strings(expired)
|
||||
for _, k := range expired {
|
||||
e := h.askOf(k)
|
||||
if err := h.closeAsk(k, asks.OutcomeExpired, "nobody answered in time: "+e.rec.Ask.OnExpiry, nil, ""); err != nil && !errors.Is(err, ErrChanged) {
|
||||
h.Logf("[messenger] could not end the expired ask %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for _, k := range untold {
|
||||
e := h.askOf(k)
|
||||
if e != nil && h.tell(*e.rec.Warrant) == nil {
|
||||
h.markTold(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// offers are an open ask's buttons on one kind: each option offered there, by its ticket.
|
||||
func (h *Holder) offers(rec *AskRecord, kind string) []Choice {
|
||||
var out []Choice
|
||||
for _, o := range rec.Ask.Options {
|
||||
for t, tk := range rec.Tickets {
|
||||
if tk.Kind == kind && tk.Option == o.ID && !tk.Used {
|
||||
out = append(out, Choice{Token: t, Label: o.Label})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// elsewhere is the line an ask's message carries on a kind for the options it cannot carry there: where
|
||||
// else each can be given — "Release or Stop: answer on Telegram, or from the mesh MCP server."
|
||||
func (h *Holder) elsewhere(rec *AskRecord, kind string) string {
|
||||
var labels []string
|
||||
where := map[string]bool{}
|
||||
for _, o := range rec.Ask.Options {
|
||||
here := false
|
||||
for _, tk := range rec.Tickets {
|
||||
if tk.Option == o.ID && tk.Kind == kind {
|
||||
here = true
|
||||
}
|
||||
}
|
||||
if here {
|
||||
continue
|
||||
}
|
||||
labels = append(labels, o.Label)
|
||||
for _, tk := range rec.Tickets {
|
||||
if tk.Option == o.ID && tk.Kind != kind {
|
||||
where[tk.Kind] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
return ""
|
||||
}
|
||||
var kinds []string
|
||||
for k := range where {
|
||||
kinds = append(kinds, Display(k))
|
||||
}
|
||||
sort.Strings(kinds)
|
||||
answer := "from the mesh MCP server"
|
||||
if len(kinds) > 0 {
|
||||
answer = "answer on " + strings.Join(kinds, " or ") + ", or from the mesh MCP server"
|
||||
}
|
||||
return joinOr(labels) + ": " + answer + "."
|
||||
}
|
||||
|
||||
func joinOr(labels []string) string {
|
||||
if len(labels) <= 1 {
|
||||
return strings.Join(labels, "")
|
||||
}
|
||||
return strings.Join(labels[:len(labels)-1], ", ") + " or " + labels[len(labels)-1]
|
||||
}
|
||||
|
||||
// judge is an answer to an ask's option, chosen on a kind: refused at the first failure, in words said on
|
||||
// the kind; otherwise the ask closed and the warrant published (ADR 0259 §5).
|
||||
func (h *Holder) judge(kind string, c asks.Choice, key string) (string, error) {
|
||||
e := h.askOf(key)
|
||||
if e == nil {
|
||||
return h.refuseChoice(kind, c, "it belongs to no ask this router holds")
|
||||
}
|
||||
rec := e.rec
|
||||
if h.Asks != nil {
|
||||
if got, _, err := h.Asks.GetAsk(key); err == nil && got != nil {
|
||||
rec = *got
|
||||
}
|
||||
}
|
||||
now := h.Now()
|
||||
t, issued := rec.Tickets[c.Ticket]
|
||||
switch {
|
||||
case !issued:
|
||||
return h.refuseChoice(kind, c, "it belongs to no ask this router holds")
|
||||
case t.Kind != kind:
|
||||
return h.refuseChoice(kind, c, "it was not offered here")
|
||||
case rec.State != StateOpen:
|
||||
return h.refuseChoice(kind, c, "the question was already answered or has ended")
|
||||
case t.Used:
|
||||
return h.refuseChoice(kind, c, "it was already chosen once")
|
||||
case !now.Before(rec.Ask.Expires):
|
||||
return h.refuseChoice(kind, c, "the question expired at "+rec.Ask.Expires.In(h.zone()).Format("15:04"))
|
||||
}
|
||||
o, offered := rec.Ask.Option(t.Option)
|
||||
if !offered {
|
||||
return h.refuseChoice(kind, c, "it names an answer the question does not offer")
|
||||
}
|
||||
info, held := h.Bench.Kind(kind)
|
||||
if !held || !h.Bench.Promises(kind, "choice") {
|
||||
return h.refuseChoice(kind, c, "this channel is not one the mesh holds for answers")
|
||||
}
|
||||
person := asks.Person{Who: asks.Operator, Kind: kind, Identity: c.Sender.Identity, Display: c.Sender.Display}
|
||||
verifies := h.Bench.Promises(kind, "verified-sender")
|
||||
var id *Identity
|
||||
if verifies {
|
||||
if !c.Sender.Authenticated {
|
||||
return h.refuseChoice(kind, c, "the service did not authenticate who sent it")
|
||||
}
|
||||
id = h.identity(kind, c.Sender.Identity)
|
||||
if id == nil {
|
||||
return h.refuseChoice(kind, c, "this account is not linked as the operator")
|
||||
}
|
||||
person.Verified = "user id verified"
|
||||
} else {
|
||||
person.Verified = "a desk click: whoever was at the operator's session on " + c.Sender.Identity
|
||||
}
|
||||
proofs := []string{}
|
||||
switch o.Level {
|
||||
case asks.Acknowledge:
|
||||
case asks.Approve:
|
||||
if !verifies {
|
||||
return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it can only acknowledge")
|
||||
}
|
||||
if now.Before(id.ApproveFrom) {
|
||||
return h.refuseChoice(kind, c, "this account was linked at "+id.Linked.In(h.zone()).Format("15:04")+
|
||||
" and can approve from "+id.ApproveFrom.In(h.zone()).Format("15:04"))
|
||||
}
|
||||
proofs = append(proofs, "P1")
|
||||
default:
|
||||
return h.refuseChoice(kind, c, "it needs a code from the authenticator app, which the mesh does not take yet")
|
||||
}
|
||||
w := &asks.Warrant{Ask: rec.Ask.ID, Asker: rec.Asker, About: rec.Ask.About, Outcome: asks.OutcomeChosen,
|
||||
Option: o.ID, Label: o.Label, Level: o.Level, By: &person, Channel: info.Module, Proofs: proofs, At: now}
|
||||
if err := h.closeAsk(key, asks.OutcomeChosen, "", w, c.Ticket); err != nil {
|
||||
if errors.Is(err, ErrChanged) {
|
||||
return h.refuseChoice(kind, c, "the question was already answered")
|
||||
}
|
||||
return h.refuseChoice(kind, c, "it could not be recorded: "+err.Error())
|
||||
}
|
||||
h.record(Sent{At: now, Channel: kind, Key: rec.Record, What: "answer " + o.Label, Outcome: "warrant to " + rec.Asker})
|
||||
h.Logf("[messenger] %s: a warrant for %s's ask %s", w.Says(), rec.Asker, rec.Ask.ID)
|
||||
return "chosen", nil
|
||||
}
|
||||
|
||||
// refuseChoice says on the kind that an answer was not taken, and why.
|
||||
func (h *Holder) refuseChoice(kind string, c asks.Choice, why string) (string, error) {
|
||||
h.Logf("[messenger] an answer on %s from %s was refused: %s", kind, c.Sender.Identity, why)
|
||||
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "answer", Outcome: "refused: " + why})
|
||||
h.showOn(kind, Message{Title: "That answer was not taken", Body: capital(why) + ". Nothing was done.",
|
||||
Quiet: true, Reply: c.ID})
|
||||
return "refused", errorf("%s", why)
|
||||
}
|
||||
|
||||
// showOn says a message of the router's own on one kind, at once: an answer's refusal, details, a link's code.
|
||||
func (h *Holder) showOn(kind string, m Message) error {
|
||||
for _, ch := range h.channels() {
|
||||
if ch.Name() == kind {
|
||||
_, err := ch.Send(m)
|
||||
h.noteChannel(kind, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if h.Publish == nil {
|
||||
return errors.New("this router cannot reach the bus")
|
||||
}
|
||||
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
|
||||
if h.Bench != nil && h.Bench.To != nil {
|
||||
out.To = h.Bench.To(kind)
|
||||
}
|
||||
err := h.Publish(asks.ChannelSubject(asks.Send, kind), out, out.Handle)
|
||||
h.noteChannel(kind, err)
|
||||
return err
|
||||
}
|
||||
|
||||
func ticket() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// AskView is one ask as the tool `asks` answers it.
|
||||
type AskView struct {
|
||||
Key string `json:"key"`
|
||||
Asker string `json:"asker"`
|
||||
Headline string `json:"headline"`
|
||||
State string `json:"state"`
|
||||
Opened string `json:"opened"`
|
||||
Expires string `json:"expires"`
|
||||
Offered []string `json:"offered_on,omitempty"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Told bool `json:"asker_told"`
|
||||
}
|
||||
|
||||
// AsksNow is every ask, open first, newest first.
|
||||
func (h *Holder) AsksNow() []AskView {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var out []AskView
|
||||
for k, e := range h.asks {
|
||||
v := AskView{Key: k, Asker: e.rec.Asker, Headline: e.rec.Ask.Headline, State: e.rec.State,
|
||||
Opened: e.rec.Opened.UTC().Format(time.RFC3339), Expires: e.rec.Ask.Expires.UTC().Format(time.RFC3339),
|
||||
Told: e.rec.Told}
|
||||
kinds := map[string]bool{}
|
||||
for _, t := range e.rec.Tickets {
|
||||
kinds[t.Kind] = true
|
||||
}
|
||||
for k := range kinds {
|
||||
v.Offered = append(v.Offered, k)
|
||||
}
|
||||
sort.Strings(v.Offered)
|
||||
if w := e.rec.Warrant; w != nil {
|
||||
if w.Outcome == asks.OutcomeChosen {
|
||||
v.Answer = w.Says()
|
||||
} else {
|
||||
v.Answer = string(w.Outcome) + ": " + w.Words
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if (out[i].State == StateOpen) != (out[j].State == StateOpen) {
|
||||
return out[i].State == StateOpen
|
||||
}
|
||||
return out[i].Opened > out[j].Opened
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -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,8 @@ 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
|
||||
}
|
||||
|
||||
// Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label.
|
||||
@@ -145,7 +147,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,13 +217,20 @@ 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
|
||||
Now func() time.Time
|
||||
Logf func(string, ...any)
|
||||
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
|
||||
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.
|
||||
@@ -255,6 +267,9 @@ 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
|
||||
}
|
||||
|
||||
func (h *Holder) init() {
|
||||
@@ -308,7 +323,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.
|
||||
@@ -532,6 +550,7 @@ 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)
|
||||
for _, r := range recs {
|
||||
switch {
|
||||
case !r.Cleared.IsZero():
|
||||
@@ -592,7 +611,35 @@ 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
|
||||
}
|
||||
if rec := h.openAsk(r); rec != nil {
|
||||
if line := h.elsewhere(rec, kind); line != "" {
|
||||
if _, ok := Check(line); ok {
|
||||
m.Body = strings.TrimSpace(m.Body + "\n" + line)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.Actions = h.choices(r, kind)
|
||||
return m
|
||||
}
|
||||
|
||||
// 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 +653,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 +687,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)
|
||||
}
|
||||
|
||||
@@ -108,7 +108,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 +163,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 +241,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 +250,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 +365,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 +396,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 +416,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 +433,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,264 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
// Link takes a link asked for on a kind: a code shown on the desk, or a refusal said on the kind.
|
||||
func (h *Holder) Link(kind string, l asks.LinkAsked) string {
|
||||
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.")
|
||||
}
|
||||
if now := h.Now(); 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.")
|
||||
}
|
||||
return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked from the mesh MCP server (messenger_unlink), and then this one can be linked.")
|
||||
}
|
||||
if h.Desk == nil || h.Desk.Ready() != nil {
|
||||
return reply("Not linked", "No desk can show the code now. 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: h.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"
|
||||
}
|
||||
desk := Message{Title: "Link a " + Display(kind) + " account as you",
|
||||
Body: fmt.Sprintf("The %s account %q 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,
|
||||
p.Expires.In(h.zone()).Format("15:04"), code[:3], code[3:]),
|
||||
Urgent: true}
|
||||
if _, err := h.Desk.Send(desk); err != nil {
|
||||
_ = h.Asks.DeleteLink(kind)
|
||||
return reply("Not linked", "The desk could not show the code: "+err.Error())
|
||||
}
|
||||
h.Logf("[messenger] a link was asked on %s by %q; its code is shown on the desk", kind, who)
|
||||
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.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 {
|
||||
return no("No link was asked for. 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, Linked: now, ApproveFrom: now.Add(LinkedApproveAfter)}
|
||||
if err := h.Asks.PutIdentity(identityKey(kind, p.Identity), id); err != nil {
|
||||
return no("The link could not be recorded: " + err.Error())
|
||||
}
|
||||
_ = h.Asks.DeleteLink(kind)
|
||||
from := id.ApproveFrom.In(h.zone()).Format("15:04")
|
||||
h.announce(Message{Title: "A " + Display(kind) + " account is now linked as you",
|
||||
Body: fmt.Sprintf("The %s account %q can answer acknowledgements now, and approve from %s. If this was not "+
|
||||
"you, unlink it from the mesh MCP server (messenger_unlink) before then.", Display(kind), p.Display, from),
|
||||
Urgent: true})
|
||||
h.record(Sent{At: now, Channel: kind, Key: "(link)", What: "linked " + p.Display, Outcome: "linked"})
|
||||
h.Logf("[messenger] linked %q on %s; it approves from %s", p.Display, kind, from)
|
||||
return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
|
||||
}
|
||||
|
||||
// Unlink removes every account linked on a kind, said on every channel.
|
||||
func (h *Holder) Unlink(kind string) (map[string]any, error) {
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
if h.Asks == nil {
|
||||
return nil, errorf("nothing is linked here")
|
||||
}
|
||||
ids := h.identitiesOf(kind)
|
||||
if len(ids) == 0 {
|
||||
return map[string]any{"kind": kind, "unlinked": []string{}}, nil
|
||||
}
|
||||
var names []string
|
||||
for _, id := range ids {
|
||||
if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, id.Display)
|
||||
}
|
||||
h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more",
|
||||
Body: "Unlinked: " + strings.Join(names, ", ") + ". Answers that need you to be proven cannot be given on " +
|
||||
Display(kind) + " until an account is linked again.", Urgent: true})
|
||||
h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: strings.Join(names, ", ")})
|
||||
return map[string]any{"kind": kind, "unlinked": names}, nil
|
||||
}
|
||||
|
||||
// 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,77 @@ 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 as ErrChanged.
|
||||
func changed(err error) error {
|
||||
if err != nil && strings.Contains(err.Error(), "written by another writer first") {
|
||||
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.") {
|
||||
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)
|
||||
}
|
||||
|
||||
// listening is whether condition events reach this holder, in words.
|
||||
type listening struct {
|
||||
mu sync.Mutex
|
||||
@@ -163,18 +219,17 @@ 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{},
|
||||
Bench: bench,
|
||||
Publish: publish,
|
||||
Ask: stdio.Ask,
|
||||
Logf: logf,
|
||||
Zone: func() *time.Location {
|
||||
loc, err := zoneOf(settings().TimeZone)
|
||||
said.Lock()
|
||||
@@ -190,6 +245,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 +311,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 +328,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 +538,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,20 +8,24 @@ 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"`
|
||||
Uses []string
|
||||
Consumes []string
|
||||
Emits []string
|
||||
Invokes []string
|
||||
State []string
|
||||
State []json.RawMessage
|
||||
Own map[string]string `json:"own-secrets"`
|
||||
Tools []string
|
||||
Build struct {
|
||||
@@ -30,9 +34,15 @@ type manifest struct {
|
||||
}
|
||||
|
||||
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 +58,49 @@ 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 one verb it calls reads the open conditions.
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions"}) {
|
||||
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"])
|
||||
if len(m.Own) != 0 {
|
||||
t.Fatalf("it holds a channel's secret: %v", m.Own)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +129,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
|
||||
}
|
||||
@@ -341,7 +348,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 +411,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,304 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
|
||||
if c.Bench.To != nil {
|
||||
out.To = c.Bench.To(kind)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.8-0.20261008145004-62367ce15ad6
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
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.8-0.20261008143736-3837f86eb6d4 h1:tB+O9kkF4cDw8By2DLQTZDRAjp5l3DltS52ypgOqLkM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008143736-3837f86eb6d4/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008144136-2f2840488818 h1:o0yf4yyH8VfTe9FE963vSGEeMR662OXf38puZzNF/ug=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008144136-2f2840488818/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 h1:JT7xM1bnLNInW7/oImV2OlXTrcQ4/GSM0Y8tAb+AhmY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
|
||||
@@ -9,7 +9,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,36 +60,45 @@
|
||||
"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"
|
||||
],
|
||||
"state": [
|
||||
"open",
|
||||
"sent"
|
||||
"sent",
|
||||
{
|
||||
"name": "asks",
|
||||
"ttl-seconds": 2592000
|
||||
},
|
||||
"identities"
|
||||
],
|
||||
"own-secrets": {
|
||||
"telegram-token": "${dir:state}/telegram-token"
|
||||
},
|
||||
"tools": [
|
||||
"messenger_status",
|
||||
"messenger_recent",
|
||||
"messenger_test",
|
||||
"messenger_check"
|
||||
"messenger_check",
|
||||
"messenger_identities",
|
||||
"messenger_unlink"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
@@ -64,7 +113,7 @@
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -80,8 +129,7 @@
|
||||
"messenger"
|
||||
],
|
||||
"env": {
|
||||
"MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json",
|
||||
"MESH_MESSENGER_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token"
|
||||
"MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user