mesh/merge-gate pass: builds messenger, telegram → 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 superseded: a newer head of the same pull request
The review found that a double tap while the phone's spinner turned would be answered 'That answer was not taken', reading as if the approval failed; it is now 'You already chose Approve at 13:30: recorded.' and does nothing more. The fallback toast no longer promises a change that may not come (a stranger's tap), the wait outlasts the router's 10 s wait for the controller's root word, the channel's own refusals stay in the chat too, and the store's error words stay in the journal.
1108 lines
39 KiB
Go
1108 lines
39 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()
|
|
// 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):
|
|
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)
|
|
}
|
|
|
|
// 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()
|
|
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
|
|
}
|