mesh/merge-gate pass: builds messenger → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A replaced ask's message was left open as if the newer ask took it over, but the newer ask opens a message of its own, so the old one kept offering answers nobody takes. It now ends on every channel with 'Replaced by a newer question', and the minute's sweep ends any such message left from before. A record that clears no longer keeps what a walk owed of it (the review of PR 238).
1137 lines
40 KiB
Go
1137 lines
40 KiB
Go
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"`
|
|
// Only is the one account that may answer it, on its kind: an ask of the router's own, put to the account
|
|
// an unlink or a relink concerns (novox/hq ADR 0259 §7).
|
|
Only *Identity `json:"only,omitempty"`
|
|
// Act is what the router does on its own ask's warrant: unlink, or link (selfact.go).
|
|
Act *SelfAct `json:"act,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
|
|
GetUnlinked(kind string) (*Unlinked, error)
|
|
PutUnlinked(kind string, u Unlinked) error
|
|
// GetLinksAsked and PutLinksAsked keep when links were asked, by account and by kind (allowLink).
|
|
GetLinksAsked(key string) ([]time.Time, error)
|
|
PutLinksAsked(key string, times []time.Time) 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, where the controller says now
|
|
// that no agent can become root (rootFree, asked live).
|
|
func (h *Holder) carriers(level asks.Level) []string {
|
|
if h.Bench == nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, k := range h.Bench.Kinds() {
|
|
// Every level needs a choice made in one act, shown exactly as the router rendered it (ADR 0234 §8).
|
|
if h.Bench.Ready(k.Kind) != nil || !h.Bench.Promises(k.Kind, "choice") || !h.Bench.Promises(k.Kind, "exact-render") {
|
|
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) && h.rootFree(k, true) == "" {
|
|
out = append(out, k.Kind)
|
|
}
|
|
}
|
|
// Destroy needs a code, which no channel carries yet.
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RootVerdict is the controller's word on one machine (its verb root-free, novox/hq ADR 0259 §8): free only
|
|
// when it measured, recently, that no agent can become root there without a person.
|
|
type RootVerdict struct {
|
|
Machine string `json:"machine"`
|
|
Free bool `json:"free"`
|
|
Why string `json:"why"`
|
|
Judged string `json:"judged,omitempty"`
|
|
}
|
|
|
|
// RootFreeVerb is the controller's verb the router asks, as this module, which machines are root-free.
|
|
const RootFreeVerb = "seat:" + ControllerSeat + ".root-free"
|
|
|
|
// RootFreeWithin is how long the router waits for the controller's word; RootFreeKept how long a word is
|
|
// used to render a message's buttons. An answer is always judged on a word asked then.
|
|
const (
|
|
RootFreeWithin = 10 * time.Second
|
|
RootFreeKept = 30 * time.Second
|
|
)
|
|
|
|
// askRootFree asks the controller about machines, failing on no answer in time.
|
|
func (h *Holder) askRootFree(machines []string) (map[string]RootVerdict, error) {
|
|
if h.RootFree != nil {
|
|
return h.RootFree(machines)
|
|
}
|
|
if h.Ask == nil {
|
|
return nil, errors.New("this router cannot ask the controller")
|
|
}
|
|
type answer struct {
|
|
raw json.RawMessage
|
|
err error
|
|
}
|
|
done := make(chan answer, 1)
|
|
go func() {
|
|
raw, err := h.Ask(RootFreeVerb, map[string]any{"machines": machines})
|
|
done <- answer{raw, err}
|
|
}()
|
|
var a answer
|
|
select {
|
|
case a = <-done:
|
|
case <-time.After(RootFreeWithin):
|
|
return nil, fmt.Errorf("the controller did not answer within %s", RootFreeWithin)
|
|
}
|
|
if a.err != nil {
|
|
return nil, a.err
|
|
}
|
|
var body struct {
|
|
Machines []RootVerdict `json:"machines"`
|
|
}
|
|
if err := json.Unmarshal(a.raw, &body); err != nil {
|
|
return nil, fmt.Errorf("the controller's answer could not be read: %v", err)
|
|
}
|
|
out := map[string]RootVerdict{}
|
|
for _, v := range body.Machines {
|
|
out[v.Machine] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// rootFree is why a kind's proof is not believed now, or "" when the controller says, now, that no agent can
|
|
// become root without a person on the router's machine and on the kind's (novox/hq ADR 0259 §8, rule 2, as
|
|
// the review of 2026-10-09 asked: a positive, recent pass, never the absence of a condition). Anything else
|
|
// fails: a router that does not know its machine, a kind whose machine is not said, a controller that does
|
|
// not answer, a machine missing from its answer, a word that is not free. cached allows a word up to
|
|
// RootFreeKept old, for rendering; an answer is always judged with cached false.
|
|
func (h *Holder) rootFree(k KindInfo, cached bool) string {
|
|
if h.Node == "" {
|
|
return "this router does not know which machine it runs on, so it cannot ask whether an agent can become root there"
|
|
}
|
|
if k.Node == "" {
|
|
return "the mesh has not said which machine " + Display(k.Kind) + " runs on"
|
|
}
|
|
machines := []string{h.Node}
|
|
if k.Node != h.Node {
|
|
machines = append(machines, k.Node)
|
|
}
|
|
now := h.Now()
|
|
key := strings.Join(machines, ",")
|
|
h.mu.Lock()
|
|
kept, ok := h.rootWord[key]
|
|
h.mu.Unlock()
|
|
var verdicts map[string]RootVerdict
|
|
switch {
|
|
case cached && ok && now.Sub(kept.at) < RootFreeKept && now.Sub(kept.at) >= 0:
|
|
verdicts = kept.verdicts
|
|
case cached:
|
|
// Read inside the work, which never waits on the controller: no recent word is no pass. warmRootFree
|
|
// asks outside it.
|
|
return "the controller's word on whether an agent can become root there is not recent"
|
|
default:
|
|
got, err := h.askRootFree(machines)
|
|
if err != nil {
|
|
// A word that could not be asked again is not kept from before: no recent word, no pass.
|
|
h.mu.Lock()
|
|
delete(h.rootWord, key)
|
|
h.mu.Unlock()
|
|
return "whether an agent can become root where it runs could not be asked: " + err.Error()
|
|
}
|
|
verdicts = got
|
|
h.mu.Lock()
|
|
if h.rootWord == nil {
|
|
h.rootWord = map[string]rootWord{}
|
|
}
|
|
h.rootWord[key] = rootWord{at: now, verdicts: got}
|
|
h.mu.Unlock()
|
|
}
|
|
for _, m := range machines {
|
|
v, said := verdicts[m]
|
|
switch {
|
|
case !said:
|
|
return "the controller said nothing of whether an agent can become root on " + m
|
|
case !v.Free:
|
|
why := v.Why
|
|
if why == "" {
|
|
why = "it is not measured that it cannot"
|
|
}
|
|
return "a program working for you may become root on " + m + " without asking you (" + why +
|
|
"), so an answer proven there proves nothing until that changes"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// warmRootFree asks the controller's root-free word for every kind that proves its sender, outside the work,
|
|
// so what is rendered and offered inside it reads a recent word.
|
|
// force asks even a recent word again: taking an ask, whose refusal its asker keeps until something changes.
|
|
func (h *Holder) warmRootFree(force bool) {
|
|
if h.Bench == nil {
|
|
return
|
|
}
|
|
now := h.Now()
|
|
for _, k := range h.Bench.Kinds() {
|
|
if !h.Bench.Promises(k.Kind, "verified-sender") || !h.linked(k.Kind) || h.Node == "" || k.Node == "" {
|
|
continue
|
|
}
|
|
key := h.Node
|
|
if k.Node != h.Node {
|
|
key += "," + k.Node
|
|
}
|
|
h.mu.Lock()
|
|
kept, ok := h.rootWord[key]
|
|
h.mu.Unlock()
|
|
if !force && ok && now.Sub(kept.at) >= 0 && now.Sub(kept.at) < RootFreeKept/2 {
|
|
continue // recent enough: asked again when half its time has gone
|
|
}
|
|
_ = h.rootFree(k, false)
|
|
}
|
|
}
|
|
|
|
// rootWordFor asks the controller's root-free word for a kind fresh, before the work is taken, and answers
|
|
// what judging an answer of that kind then reads: the word for that very kind's placement, or why there is none.
|
|
// A kind that does not prove its sender is asked nothing: its answers never approve.
|
|
func (h *Holder) rootWordFor(kind string) func(KindInfo) string {
|
|
if h.Bench == nil {
|
|
return func(KindInfo) string { return "the router does not know the channels yet" }
|
|
}
|
|
asked, held := h.Bench.Kind(kind)
|
|
if !held || !h.Bench.Promises(kind, "verified-sender") {
|
|
return func(KindInfo) string { return Display(kind) + " cannot prove who answered" }
|
|
}
|
|
why := h.rootFree(asked, false)
|
|
return func(info KindInfo) string {
|
|
if info.Kind != asked.Kind || info.Node != asked.Node || info.Module != asked.Module {
|
|
return "where " + Display(kind) + " runs changed while the answer was judged; answer again"
|
|
}
|
|
return why
|
|
}
|
|
}
|
|
|
|
// rootWord is the controller's last word on a set of machines, and when it was asked.
|
|
type rootWord struct {
|
|
at time.Time
|
|
verdicts map[string]RootVerdict
|
|
}
|
|
|
|
// approveHolds is why an answer on a kind could not approve now, or "": the kind verifies its sender, an
|
|
// account is linked there, and the controller says no agent can become root where it runs.
|
|
func (h *Holder) approveHolds(kind string, cached bool) string {
|
|
info, held := h.Bench.Kind(kind)
|
|
switch {
|
|
case !held:
|
|
return "this channel is not one the mesh holds for answers"
|
|
case !h.Bench.Promises(kind, "verified-sender"):
|
|
return Display(kind) + " cannot prove who answered"
|
|
case !h.linked(kind):
|
|
return "no " + Display(kind) + " account is linked as you"
|
|
}
|
|
return h.rootFree(info, cached)
|
|
}
|
|
|
|
// answerable is whether an option of an ask is answered on a kind, by the ask's highest level: an ask that
|
|
// offers an approval is answered only where it could be approved, whichever of its options is chosen, so an
|
|
// acknowledgement from where approval is not proven never ends a question that waits for one (the review of
|
|
// 2026-10-09).
|
|
func answerLevel(a asks.Ask) asks.Level {
|
|
if a.Highest().Rank() > asks.Acknowledge.Rank() {
|
|
return a.Highest()
|
|
}
|
|
return asks.Acknowledge
|
|
}
|
|
|
|
// ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which
|
|
// raises the conditions.
|
|
const ConditionOwner = "mesh-controller"
|
|
|
|
// 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.warmRootFree(true) // fresh, before the work: an ask refused for no channel stays refused with its asker
|
|
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, "", 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{}}
|
|
// Every option on the kinds that carry the ask's highest level: an ask with an approval is answered only
|
|
// where it could be approved (answerLevel).
|
|
for _, kind := range h.carriers(answerLevel(a)) {
|
|
for _, o := range a.Options {
|
|
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, "", 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]
|
|
// Shown on a condition's message only when asked by the condition's owner: another asker naming a
|
|
// condition's key does not take its message over.
|
|
if a.About != "" && asker == ConditionOwner {
|
|
if r := h.open[a.About]; r != nil && r.Cleared.IsZero() && r.Origin == "condition" {
|
|
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")
|
|
}
|
|
// A new message id each time (the review of 2026-10-09): an id anyone could foresee lets a publisher on
|
|
// the same stream take it first, and the bus would drop the warrant as a duplicate. The asker acts on its
|
|
// own record of the ask once, however many times it hears a warrant.
|
|
err := h.Publish(asks.DecidedSubject(w.Asker), w, "warrant."+ticket())
|
|
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. confirm, when given, is called once the end is recorded
|
|
// and the asker told or not (told), before any copy is edited: the answer's confirmation where it was given
|
|
// comes first, and never before the record stands (issue 372).
|
|
func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *asks.Warrant, used string, confirm func(told bool)) 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 rec.Asker == SelfAsker {
|
|
// The router's own ask: it does what was decided, and tells nobody else (selfact.go).
|
|
h.markTold(key)
|
|
if confirm != nil {
|
|
confirm(true)
|
|
}
|
|
h.showClosed(rec)
|
|
h.selfAct(rec, *w)
|
|
return nil
|
|
}
|
|
told := h.tell(*w) == nil
|
|
if told {
|
|
h.markTold(key)
|
|
}
|
|
if confirm != nil {
|
|
confirm(told)
|
|
}
|
|
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
|
|
}
|
|
told := h.toldLine(rec) // read before the lock: it reads the ask's record
|
|
h.mu.Lock()
|
|
ownMessage := r.Origin == "ask"
|
|
// The first line is how it ended, which an ask's own message shows as its title (sayOn); the rest follows.
|
|
switch {
|
|
case rec.Warrant != nil && rec.Warrant.Outcome == asks.OutcomeChosen:
|
|
r.Answered = chosenLine(rec.Warrant, rec.Closed.In(h.zone())) + "\n" + told
|
|
case rec.State == string(asks.OutcomeExpired):
|
|
r.Answered = "No longer answerable: nobody answered in time.\n" + strings.TrimSuffix(capital(rec.Ask.OnExpiry), ".") + "."
|
|
case rec.State == string(asks.OutcomeReplaced) && !ownMessage:
|
|
// On a condition's message, which the newer ask takes over and shows in place.
|
|
r.Answered = ""
|
|
case rec.State == string(asks.OutcomeReplaced):
|
|
// Its own message: the newer ask looks for its message under its own key and opens one of its own, so
|
|
// this one ends here, on every channel that showed it (issue 548).
|
|
r.Answered = "Replaced by a newer question.\n" + rec.Asker + " asked again about the same thing; that question is the one to answer."
|
|
default:
|
|
r.Answered = "No longer needed."
|
|
}
|
|
h.mu.Unlock()
|
|
if r.told() {
|
|
h.edit(r, "answered", false)
|
|
}
|
|
if ownMessage {
|
|
h.mu.Lock()
|
|
r.Cleared = h.Now()
|
|
r.Owed = "" // an ended question is owed to nobody (the review of PR 238)
|
|
h.mu.Unlock()
|
|
}
|
|
h.persist(r)
|
|
}
|
|
|
|
// endLeftovers ends the own message of an ask that already ended and whose message stayed open: one left by a
|
|
// router before issue 548 was fixed, whose replaced asks kept their messages for good. Its copies say how the
|
|
// ask ended, as its end would have.
|
|
func (h *Holder) endLeftovers() {
|
|
h.mu.Lock()
|
|
var ended []AskRecord
|
|
for _, r := range h.open {
|
|
if r.Origin != "ask" || r.Ask == "" || !r.Cleared.IsZero() {
|
|
continue
|
|
}
|
|
if e := h.asks[r.Ask]; e != nil && e.rec.State != StateOpen && e.rec.Record == r.Key {
|
|
ended = append(ended, e.rec)
|
|
}
|
|
}
|
|
h.mu.Unlock()
|
|
sort.Slice(ended, func(i, j int) bool { return ended[i].Record < ended[j].Record })
|
|
for _, rec := range ended {
|
|
h.Logf("[messenger] the message of %s stayed open after its ask ended (%s): ended now", rec.Record, rec.State)
|
|
h.showClosed(rec)
|
|
}
|
|
}
|
|
|
|
// chosenLine is how a chosen ask ended, in the operator's words: "You chose Approve at 13:30 on Telegram."
|
|
func chosenLine(w *asks.Warrant, at time.Time) string {
|
|
who, where := "Somebody", ""
|
|
if w.By != nil {
|
|
if w.By.Who == asks.Operator {
|
|
who = "You"
|
|
}
|
|
where = " on " + Display(w.By.Kind)
|
|
}
|
|
return who + " chose " + w.Label + " at " + at.Format("15:04") + where + "."
|
|
}
|
|
|
|
// toldLine is what the router knows of the asker after an answer: whether it was told. The asker says nothing
|
|
// back of what it then did (the asks protocol carries no result to the router), so nothing more is claimed.
|
|
func (h *Holder) toldLine(rec AskRecord) string {
|
|
if rec.Asker == SelfAsker {
|
|
return "The router does it itself."
|
|
}
|
|
if e := h.askOf(askKey(rec.Asker, rec.Ask.ID)); e != nil && e.rec.Told {
|
|
return "Told to " + rec.Asker + ", which acts on it itself."
|
|
}
|
|
return "Told to " + rec.Asker + " as soon as the bus takes it; it acts on it itself."
|
|
}
|
|
|
|
// confirmChoice says, where an answer was given and in reply to it, that it was recorded: on Telegram the
|
|
// tap's own acknowledgement (issue 372). Called only once the ask's end is recorded.
|
|
func (h *Holder) confirmChoice(kind string, c asks.Choice, label, asker string, told bool) {
|
|
words := "You chose " + label + ": recorded"
|
|
switch {
|
|
case asker == SelfAsker:
|
|
words += "."
|
|
case told:
|
|
words += ", and " + asker + " was told."
|
|
default:
|
|
words += "; " + asker + " is told as soon as the bus takes it."
|
|
}
|
|
err := h.showOn(kind, Message{Title: words, Quiet: true, Reply: c.ID})
|
|
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "confirmed " + label, Outcome: outcomeOf(err)})
|
|
}
|
|
|
|
// confirmAgain says, in reply to the same answer given again, that it was already recorded.
|
|
func (h *Holder) confirmAgain(kind string, c asks.Choice, label string, at time.Time) {
|
|
err := h.showOn(kind, Message{Title: "You already chose " + label + " at " + at.In(h.zone()).Format("15:04") + ": recorded.",
|
|
Quiet: true, Reply: c.ID})
|
|
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "confirmed again " + label, Outcome: outcomeOf(err)})
|
|
}
|
|
|
|
// 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()
|
|
h.endLeftovers()
|
|
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, "", 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
|
|
if answerLevel(rec.Ask) != asks.Acknowledge && rec.Asker != SelfAsker {
|
|
// Rendered only while approval holds there now, by the controller's recent word.
|
|
if h.approveHolds(kind, true) != "" {
|
|
return nil
|
|
}
|
|
} else if rec.Asker == SelfAsker && answerLevel(rec.Ask) != asks.Acknowledge {
|
|
if info, held := h.Bench.Kind(kind); !held || h.rootFree(info, true) != "" {
|
|
return nil
|
|
}
|
|
}
|
|
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, root func(KindInfo) 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]
|
|
if w := rec.Warrant; issued && rec.State == string(asks.OutcomeChosen) && w != nil && w.Option == t.Option &&
|
|
w.By != nil && w.By.Kind == kind && w.By.Identity == c.Sender.Identity && (c.Sender.Authenticated || !h.Bench.Promises(kind, "verified-sender")) {
|
|
// The same answer again from the same account — a second tap while the first was being confirmed, or one
|
|
// redelivered after a restart: it is what was recorded, so it is said so, not refused as if it failed
|
|
// (issue 372). Nothing more is done.
|
|
h.confirmAgain(kind, c, w.Label, rec.Closed)
|
|
return "chosen", nil
|
|
}
|
|
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") || !h.Bench.Promises(kind, "exact-render") {
|
|
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 rec.Only != nil {
|
|
// An ask put to one account is answered by that account alone, linked or not any more.
|
|
if rec.Only.Kind != kind || rec.Only.Identity != c.Sender.Identity {
|
|
return h.refuseChoice(kind, c, "this question was put to another account")
|
|
}
|
|
only := *rec.Only
|
|
id = &only
|
|
}
|
|
if id == nil {
|
|
return h.refuseStranger(kind, c)
|
|
}
|
|
person.Verified = "user id verified"
|
|
} else {
|
|
if rec.Only != nil && rec.Only.Kind != kind {
|
|
return h.refuseChoice(kind, c, "this question was put to another channel")
|
|
}
|
|
person.Verified = "a desk click: whoever was at the operator's session on " + c.Sender.Identity
|
|
}
|
|
// Judged at the ask's highest level, whichever option was chosen (answerLevel): an acknowledgement on an
|
|
// ask that offers an approval needs what the approval needs, so it never ends that ask from where an
|
|
// approval is not proven.
|
|
level := o.Level
|
|
if answerLevel(rec.Ask).Rank() > level.Rank() {
|
|
level = answerLevel(rec.Ask)
|
|
}
|
|
proofs := []string{}
|
|
switch level {
|
|
case asks.Acknowledge:
|
|
case asks.Approve:
|
|
if !verifies {
|
|
return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it cannot answer a question "+
|
|
"that asks for an approval")
|
|
}
|
|
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 answer this question from "+id.ApproveFrom.In(h.zone()).Format("15:04"))
|
|
}
|
|
if why := root(info); why != "" {
|
|
return h.refuseChoice(kind, c, why)
|
|
}
|
|
if o.Level == asks.Approve {
|
|
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,
|
|
// The warrant answers this ask as it was taken and shown, with what each option binds (ADR 0259 §6).
|
|
AskDigest: rec.Ask.Digest()}
|
|
confirm := func(told bool) { h.confirmChoice(kind, c, o.Label, rec.Asker, told) }
|
|
if err := h.closeAsk(key, asks.OutcomeChosen, "", w, c.Ticket, confirm); err != nil {
|
|
if errors.Is(err, ErrChanged) {
|
|
return h.refuseChoice(kind, c, "the question was already answered")
|
|
}
|
|
// The store's own words stay in the journal: they are not the operator's to read on the phone.
|
|
h.Logf("[messenger] the answer to %s's ask %s could not be recorded: %v", rec.Asker, rec.Ask.ID, err)
|
|
return h.refuseChoice(kind, c, "it could not be recorded")
|
|
}
|
|
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
|
|
}
|
|
|
|
// refuseStranger refuses an answer from an account that is not the operator's, and tells the operator, by name
|
|
// and number, on the account that is linked: a question's buttons reached somebody else (a forwarded message,
|
|
// or a phone in other hands). Nothing is said to the other account.
|
|
func (h *Holder) refuseStranger(kind string, c asks.Choice) (string, error) {
|
|
why := "this account is not linked as the operator"
|
|
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})
|
|
who := c.Sender.Display
|
|
if who == "" {
|
|
who = "an account"
|
|
}
|
|
_ = h.showOn(kind, Message{Title: "Another account tried to answer for you",
|
|
Body: fmt.Sprintf("The %s account %s (number %s) chose an answer on a question meant for you. It was refused "+
|
|
"and nothing was done. If you did not pass the question on, somebody else can read your messages.",
|
|
Display(kind), who, c.Sender.Identity), Urgent: true})
|
|
return "refused", errorf("%s", why)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// showOnHandle is showOn, answering the handle the message is known by.
|
|
func (h *Holder) showOnHandle(kind string, m Message) (string, error) {
|
|
for _, ch := range h.channels() {
|
|
if ch.Name() == kind {
|
|
id, err := ch.Send(m)
|
|
h.noteChannel(kind, err)
|
|
return id, 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, To: m.To,
|
|
Secret: m.Secret}
|
|
if out.To == "" && h.Bench != nil && h.Bench.To != nil {
|
|
out.To = h.Bench.To(kind)
|
|
}
|
|
for _, a := range m.Actions {
|
|
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
|
|
}
|
|
err := h.Publish(asks.ChannelSubject(asks.Show, kind), out, out.Handle)
|
|
h.noteChannel(kind, err)
|
|
return out.Handle, err
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
if m.To != "" {
|
|
out.To = m.To
|
|
}
|
|
out.Secret = m.Secret
|
|
for _, a := range m.Actions {
|
|
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
|
|
}
|
|
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
|
|
}
|