messenger, desk-channel, telegram, mesh-config take the channel benches' shapes from the operator-channel contract and its client (B5d; ADR 0634 §1, §6; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 15:03:24 +01:00
parent f61760f738
commit 1d3ed6e78e
20 changed files with 266 additions and 257 deletions
+22 -21
View File
@@ -35,7 +35,8 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/channel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// Kind is this channel's kind on both benches.
@@ -59,19 +60,19 @@ const StandingEvery = 5 * time.Minute
// Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost
// the answers its notifications waited for (its bundle started again). The router then says again what is
// still open and unanswered (issue 384). It belongs in channel.Standing once the SDK carries it; until then the
// still open and unanswered (issue 384). It belongs in oc.Standing once the SDK carries it; until then the
// field travels as JSON the router reads under the same name. Zero (as "0001-01-01T00:00:00Z") until then.
type Standing struct {
channel.Standing
oc.Standing
AnswersLostAt time.Time `json:"answers-lost-at"`
}
// Shown is one of the router's messages as the desk shows it: the message, and its notification on each
// machine.
type Shown struct {
Message channel.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
Message oc.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
}
// Store keeps what is shown, by the router's handle.
@@ -146,7 +147,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
d.Logf("[desk-channel] work on %s is not this kind's; taken and dropped", subject)
return nil
}
var m channel.Message
var m oc.Message
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
return nil
@@ -158,8 +159,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// would appear later, to whoever is at the screen then.
seeing, why := d.seeing()
if len(seeing) == 0 {
f := channel.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
return d.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
f := oc.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
return d.Publish(occ.IntakeSubject(occ.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
}
machines = seeing
}
@@ -176,7 +177,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// shown (a reminder, an escalation), replace the notification it had on each machine — dunst's replace id —
// so a problem is one notification updated in place, never one more beside it. An edit stays quiet where
// it was shown; a message said again is shown anew where it was not.
edit := parts[4] == channel.Edit
edit := parts[4] == occ.Edit
var before *Shown
switch {
case edit:
@@ -226,8 +227,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
if len(machines) == 0 {
why = "no machine to show it on: the setting desktop-machines names none"
}
f := channel.Failed{Handle: m.Handle, Why: why, At: d.Now()}
if err := d.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
f := oc.Failed{Handle: m.Handle, Why: why, At: d.Now()}
if err := d.Publish(occ.IntakeSubject(occ.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
return err
}
return nil
@@ -322,7 +323,7 @@ func sameShown(a, b Shown) bool {
}
// show puts one message on one machine's notifier, in place of `replace` when it names one.
func (d *Desk) show(machine string, m channel.Message, replace int) (int, error) {
func (d *Desk) show(machine string, m oc.Message, replace int) (int, error) {
urgency := "normal"
switch {
case m.Urgent:
@@ -376,12 +377,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
handle, shown := d.find(ticket)
if shown == nil {
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
_, _ = d.show(machine, oc.Message{Title: "That answer was not taken",
Body: "It belongs to no message shown here any more. Nothing was done.", Silent: true}, 0)
return
}
if _, here := shown.On[machine]; !here {
_, _ = d.show(machine, channel.Message{Title: "That answer was not taken",
_, _ = d.show(machine, oc.Message{Title: "That answer was not taken",
Body: "It was not shown on " + machine + ". Nothing was done.", Silent: true}, 0)
return
}
@@ -395,11 +396,11 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
d.clickedOn[id] = machine
d.mu.Unlock()
c := channel.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
Sender: channel.Sender{Identity: machine, Display: machine, Authenticated: false}}
if err := d.Publish(channel.IntakeSubject(channel.Chosen, Kind), c, id); err != nil {
c := oc.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
Sender: oc.Sender{Identity: machine, Display: machine, Authenticated: false}}
if err := d.Publish(occ.IntakeSubject(occ.Chosen, Kind), c, id); err != nil {
d.Logf("[desk-channel] a choice could not be said to the router: %v", err)
_, _ = d.show(machine, channel.Message{Title: "That answer could not be taken now",
_, _ = d.show(machine, oc.Message{Title: "That answer could not be taken now",
Body: "The mesh did not take it. Try again in a minute.", Silent: true}, 0)
}
}
@@ -508,7 +509,7 @@ func (d *Desk) seeing() ([]string, string) {
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
func (d *Desk) Standing() Standing {
d.init()
s := Standing{Standing: channel.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
s := Standing{Standing: oc.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
if _, why := d.seeing(); why != "" {
s.Why = why
return s
@@ -542,7 +543,7 @@ func (d *Desk) SayStanding() {
if same {
return
}
if err := d.Publish(channel.IntakeSubject(channel.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
if err := d.Publish(occ.IntakeSubject(occ.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
d.Logf("[desk-channel] could not say whether it can send: %v", err)
return
}
@@ -10,7 +10,8 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/channel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
type memShown map[string]Shown
@@ -62,10 +63,10 @@ func newDesk(t *testing.T, machines ...string) (*Desk, *[]notified, map[string][
return d, &shown, said, store
}
func take(t *testing.T, d *Desk, verb string, m channel.Message) {
func take(t *testing.T, d *Desk, verb string, m oc.Message) {
t.Helper()
raw, _ := json.Marshal(m)
if err := d.Take(channel.ChannelSubject(verb, Kind), raw); err != nil {
if err := d.Take(occ.ChannelSubject(verb, Kind), raw); err != nil {
t.Fatal(err)
}
}
@@ -74,16 +75,16 @@ func take(t *testing.T, d *Desk, verb string, m channel.Message) {
// says a click as a choice the router judges — only on a machine the message was shown on.
func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
d, shown, said, _ := newDesk(t, "laptop", "away-machine")
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}})
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["expire_ms"] != 0 ||
(*shown)[0].args["body"] != "Needs you: &lt;mend&gt; them." {
t.Fatalf("shown %+v", *shown)
}
d.Chosen("e1", "laptop", "t1")
d.Chosen("e1", "laptop", "t1") // a redelivery
got := said[channel.IntakeSubject(channel.Chosen, Kind)]
var c channel.Choice
got := said[occ.IntakeSubject(occ.Chosen, Kind)]
var c oc.Choice
if len(got) != 1 || json.Unmarshal(got[0], &c) != nil || c.Ticket != "t1" || c.Handle != "m1" ||
c.Sender.Identity != "laptop" || c.Sender.Authenticated {
t.Fatalf("said %s", got)
@@ -91,14 +92,14 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
// Elsewhere it is refused, there, and nothing is said to the router.
d.Chosen("e2", "workstation", "t1")
d.Chosen("e3", "laptop", "forged")
if len(said[channel.IntakeSubject(channel.Chosen, Kind)]) != 1 {
if len(said[occ.IntakeSubject(occ.Chosen, Kind)]) != 1 {
t.Error("a click not shown there was said")
}
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "That answer was not taken" {
t.Errorf("the refusal was not shown where it was chosen: %+v", last)
}
// The router's reply to the click is shown on the machine it was made on.
take(t, d, channel.Send, channel.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
take(t, d, occ.Send, oc.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "Details: 3 failed services" {
t.Errorf("%+v", last)
}
@@ -107,8 +108,8 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
// The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639).
func TestTheNoteFollowsTheBody(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}})
if last := (*shown)[len(*shown)-1]; last.args["body"] != "The summary.\n\nAsked by mesh-knowledge at 16:05." {
t.Errorf("the body: %q", last.args["body"])
}
@@ -116,8 +117,8 @@ func TestTheNoteFollowsTheBody(t *testing.T) {
func TestAnEditReplacesTheNotification(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "q", Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, channel.Edit, channel.Message{Handle: "m1", Title: "Silence for a week: chosen"})
take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "q", Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, occ.Edit, oc.Message{Handle: "m1", Title: "Silence for a week: chosen"})
if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil {
t.Errorf("the edit: %+v", last.args)
}
@@ -134,17 +135,17 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop")
d.Paused("laptop", false)
d.SayStanding()
take(t, d, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, channel.Show, channel.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
take(t, d, channel.Edit, channel.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
take(t, d, channel.Show, channel.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []channel.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, channel.Edit, channel.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
take(t, d, occ.Show, oc.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, occ.Show, oc.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
take(t, d, occ.Edit, oc.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
take(t, d, occ.Show, oc.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []oc.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, occ.Edit, oc.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
openID := store["open"].On["laptop"]
before := len(*shown)
standings := len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
standings := len(said[occ.IntakeSubject(occ.StandingWhat, Kind)])
later := d.Now().Add(time.Hour)
d.Now = func() time.Time { return later }
d.Paused("laptop", false)
@@ -157,25 +158,25 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
if store["open"].On["laptop"] != openID {
t.Fatalf("the open message's notification was forgotten: %+v", store["open"].On)
}
got := said[channel.IntakeSubject(channel.StandingWhat, Kind)]
got := said[occ.IntakeSubject(occ.StandingWhat, Kind)]
var s Standing
if len(got) != standings+1 || json.Unmarshal(got[len(got)-1], &s) != nil || !s.Ready || !s.AnswersLostAt.Equal(later) {
t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1])
}
// The router says the open one again under its handle: in place of its notification, with its answers.
take(t, d, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, occ.Show, oc.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
last := (*shown)[len(*shown)-1]
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
t.Fatalf("the open message said again: %+v", last.args)
}
d.Chosen("e2", "laptop", "t-open")
if len(said[channel.IntakeSubject(channel.Chosen, Kind)]) != 1 {
if len(said[occ.IntakeSubject(occ.Chosen, Kind)]) != 1 {
t.Error("a click on the message said again was not said to the router")
}
// The same start event again (a redelivery) does nothing more.
standings = len(said[channel.IntakeSubject(channel.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[channel.IntakeSubject(channel.StandingWhat, Kind)]) != standings {
standings = len(said[occ.IntakeSubject(occ.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[occ.IntakeSubject(occ.StandingWhat, Kind)]) != standings {
t.Errorf("a redelivered start was acted on again: %d", n)
}
// A desk none of whose notifiers started again says a zero time, which the router reads as none.
@@ -187,8 +188,8 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) {
d, _, said, _ := newDesk(t, "away-machine")
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "x"})
if f := said[channel.IntakeSubject(channel.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "x"})
if f := said[occ.IntakeSubject(occ.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
strings.Contains(string(f[0]), `"permanent":true`) {
t.Errorf("%s", f)
}
@@ -229,7 +230,7 @@ func TestItPromisesNoVerifiedSender(t *testing.T) {
func TestASecretMessageIsShownAndNotKept(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
d.Paused("laptop", false)
take(t, d, channel.Send, channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
take(t, d, occ.Send, oc.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
if len(*shown) != 1 {
t.Fatal("not shown")
}
@@ -252,8 +253,8 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
if s := d.Standing(); !s.Ready {
t.Fatalf("not ready with laptop heard unpaused: %+v", s)
}
code := channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, channel.Send, code)
code := oc.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, occ.Send, code)
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true {
t.Fatalf("the code was shown as %+v", *shown)
}
@@ -262,11 +263,11 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
}
// Paused: not shown anywhere, said as failed, and the desk is not ready.
d.Paused("laptop", true)
take(t, d, channel.Send, code)
take(t, d, occ.Send, code)
if len(*shown) != 1 {
t.Fatalf("the code was shown on a paused notifier: %+v", *shown)
}
if f := said[channel.IntakeSubject(channel.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
if f := said[occ.IntakeSubject(occ.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
t.Errorf("not said as failed: %s", f)
}
if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "paused") {
@@ -284,7 +285,7 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin
t.Error("a machine not named made the desk ready")
}
// A message that is not secret still goes to every named machine, paused or not.
take(t, d, channel.Send, channel.Message{Handle: "m9", Title: "x"})
take(t, d, occ.Send, oc.Message{Handle: "m9", Title: "x"})
if len(*shown) != 3 {
t.Errorf("an ordinary message was held back: %+v", *shown)
}
@@ -330,8 +331,8 @@ func TestItRunsAsItsOwnAccount(t *testing.T) {
// was not shown; a message under a new handle replaces nothing.
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop", "desktop")
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, channel.Send, channel.Message{Handle: "m2", Title: "another problem"})
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, occ.Send, oc.Message{Handle: "m2", Title: "another problem"})
if len(*shown) != 4 {
t.Fatalf("shown %d", len(*shown))
}
@@ -343,8 +344,8 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
first := store["m1"].On
// The desk now names a third machine as well.
d.Machines = func() []string { return []string{"laptop", "desktop", "third"} }
take(t, d, channel.Show, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}})
again := (*shown)[4:]
if len(again) != 3 {
t.Fatalf("said again on %d machines", len(again))
@@ -397,18 +398,18 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
down := true
d.Store = downStore{store, &down}
raised, _ := json.Marshal(channel.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(channel.ChannelSubject(channel.Send, Kind), raised); err != nil {
raised, _ := json.Marshal(oc.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(occ.ChannelSubject(occ.Send, Kind), raised); err != nil {
t.Fatalf("the raise was not taken: %v", err)
}
cleared, _ := json.Marshal(channel.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(channel.ChannelSubject(channel.Edit, Kind), cleared); err != nil {
cleared, _ := json.Marshal(oc.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(occ.ChannelSubject(occ.Edit, Kind), cleared); err != nil {
t.Fatalf("the clearing was not taken: %v", err)
}
d.KeepUnkept() // the minute's standing, store still down
down = !back
d.KeepUnkept()
take(t, d, channel.Send, channel.Message{Handle: "m10", Title: "another"})
take(t, d, occ.Send, oc.Message{Handle: "m10", Title: "another"})
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
}
@@ -423,7 +424,7 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
d, shown, said, store := newDesk(t, "laptop", "desktop")
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "2 failed services on shanks"})
first := store["m1"].On
notify := d.Notify
d.Notify = func(machine string, args map[string]any) (json.RawMessage, error) {
@@ -432,13 +433,13 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
return notify(machine, args)
}
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
if got := store["m1"].On["desktop"]; got != first["desktop"] {
t.Fatalf("the desktop's notification %d was forgotten: %v", first["desktop"], store["m1"].On)
}
d.Notify = func(string, map[string]any) (json.RawMessage, error) { return nil, errors.New("away") }
take(t, d, channel.Send, channel.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[channel.IntakeSubject(channel.FailedWhat, Kind)]) != 1 {
take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[occ.IntakeSubject(occ.FailedWhat, Kind)]) != 1 {
t.Fatalf("shown nowhere, and not said as failed")
}
_ = shown
+5
View File
@@ -12,3 +12,8 @@ require (
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
require (
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
)
@@ -37,7 +37,6 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
ch "git.novox.be/novox/mesh-sdk/go/channel"
"git.novox.be/novox/mesh-sdk/go/outward"
)
@@ -428,7 +427,7 @@ func approverReady(bus AskBus) string {
}
channels := 0
for _, k := range kinds {
if k.Seat != ch.ChannelSeat {
if k.Seat != occ.ChannelSeat {
continue
}
channels++
@@ -13,7 +13,6 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
// standIn is the operator channel's router and a channel that proves who answered, in memory: it takes the asks
@@ -37,7 +36,7 @@ type standIn struct {
func newStandIn() *standIn {
return &standIn{asks: map[string]oc.Ask{}, records: map[string][]byte{}, kinds: []stdio.KindHeld{
{Seat: ch.ChannelSeat, Kind: "telegram", Module: "telegram", Node: "novox",
{Seat: occ.ChannelSeat, Kind: "telegram", Module: "telegram", Node: "novox",
Capabilities: []string{"choice", "exact-render", "verified-sender"}}}}
}
@@ -459,7 +458,7 @@ func TestAProposalIsJudgedAndRefusedBeforeTheOperatorIsAsked(t *testing.T) {
t.Fatalf("a refused proposal was asked: %v", r.subjects)
}
r.kinds = []stdio.KindHeld{{Seat: ch.ChannelSeat, Kind: "desktop", Capabilities: []string{"choice"}}}
r.kinds = []stdio.KindHeld{{Seat: occ.ChannelSeat, Kind: "desktop", Capabilities: []string{"choice"}}}
if _, err := c.Propose(Call{Action: "propose", Key: "font-size", Value: 13.0, Caller: "g", Why: "w"}); err == nil ||
!strings.Contains(err.Error(), "no channel that proves who answered") {
t.Errorf("no approving channel: %v", err)
+3 -3
View File
@@ -26,7 +26,7 @@ import (
"strings"
"time"
ch "git.novox.be/novox/mesh-sdk/go/channel"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// Action is one answer a condition names: a label, and the seat verb its producer calls when it is chosen.
@@ -132,7 +132,7 @@ func pruneGrants(grants map[string]Grant, kind string, now time.Time, keep int)
// 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 ch.Choice) (string, error) {
func (h *Holder) Chosen(kind string, c oc.Choice) (string, error) {
// The root-free word is asked before the work is taken (the confirmation review of 2026-10-09):
// up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer,
// and judged with in it.
@@ -255,7 +255,7 @@ func firstLineOf(s string) string {
// 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 ch.Failed) {
func (h *Holder) Failed(kind string, f oc.Failed) {
h.work.Lock()
defer h.work.Unlock()
h.noteChannel(kind, errorf("%s", f.Why))
+21 -22
View File
@@ -12,7 +12,6 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
// The router of novox/hq ADR 0259: it holds every ask, judges each answer and issues a warrant; it
@@ -252,9 +251,9 @@ func link(h *Holder, store *memAsks, c *clock, identity string, at time.Time) {
Linked: at, ApproveFrom: at.Add(LinkedApproveAfter)}
}
func choiceOf(ticket, identity string, at time.Time) ch.Choice {
return ch.Choice{ID: "c-" + ticket + "-" + identity, Ticket: ticket, At: at,
Sender: ch.Sender{Identity: identity, Display: "Jo", Authenticated: true}}
func choiceOf(ticket, identity string, at time.Time) oc.Choice {
return oc.Choice{ID: "c-" + ticket + "-" + identity, Ticket: ticket, At: at,
Sender: oc.Sender{Identity: identity, Display: "Jo", Authenticated: true}}
}
func ticketFor(m Message, label string) string {
@@ -737,7 +736,7 @@ func TestAnAnswerReplayedLongAfterIsNotActedOn(t *testing.T) {
func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
asked := ch.LinkAsked{ID: "l1", At: c.now(), Sender: ch.Sender{Identity: "42", Display: "Jo", Authenticated: true}}
asked := oc.LinkAsked{ID: "l1", At: c.now(), Sender: oc.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)
@@ -750,13 +749,13 @@ func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
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", ch.Code{Sender: ch.Sender{Identity: "666", Authenticated: true}, Code: code}); a.Accepted {
if a := h.Prove("telegram", oc.Code{Sender: oc.Sender{Identity: "666", Authenticated: true}, Code: code}); a.Accepted {
t.Error("another account linked with the code")
}
if a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "42", Authenticated: true}, Code: "000000x"}); a.Accepted {
if a := h.Prove("telegram", oc.Code{Sender: oc.Sender{Identity: "42", Authenticated: true}, Code: "000000x"}); a.Accepted {
t.Error("a wrong code linked")
}
a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"})
a := h.Prove("telegram", oc.Code{Sender: oc.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)
}
@@ -771,7 +770,7 @@ func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
}
}
// A second account cannot link while one is.
h.Link("telegram", ch.LinkAsked{ID: "l2", At: c.now(), Sender: ch.Sender{Identity: "666", Authenticated: true}})
h.Link("telegram", oc.LinkAsked{ID: "l2", At: c.now(), Sender: oc.Sender{Identity: "666", Authenticated: true}})
if _, pending := store.links["telegram"]; pending {
t.Error("a second link was begun while one is linked")
}
@@ -779,21 +778,21 @@ func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) {
func TestALinkCodeExpiresAndFiveWrongTriesEndIt(t *testing.T) {
h, _, _, c, store, _ := newRouter(t)
jo := ch.Sender{Identity: "42", Authenticated: true}
h.Link("telegram", ch.LinkAsked{ID: "l1", At: c.now(), Sender: jo})
jo := oc.Sender{Identity: "42", Authenticated: true}
h.Link("telegram", oc.LinkAsked{ID: "l1", At: c.now(), Sender: jo})
for i := 0; i < LinkTries; i++ {
h.Prove("telegram", ch.Code{Sender: jo, Code: "nope"})
h.Prove("telegram", oc.Code{Sender: jo, Code: "nope"})
}
if _, pending := store.links["telegram"]; pending {
t.Error("five wrong codes did not end the link")
}
h.Link("telegram", ch.LinkAsked{ID: "l2", At: c.now(), Sender: jo})
h.Link("telegram", oc.LinkAsked{ID: "l2", At: c.now(), Sender: jo})
c.pass(LinkCodeFor)
if a := h.Prove("telegram", ch.Code{Sender: jo, Code: "123456"}); a.Accepted || !strings.Contains(a.Words, "expired") {
if a := h.Prove("telegram", oc.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", ch.LinkAsked{ID: "l3", At: c.now(), Sender: ch.Sender{Identity: "g14"}})
h.Link("desktop", oc.LinkAsked{ID: "l3", At: c.now(), Sender: oc.Sender{Identity: "g14"}})
if _, pending := store.links["desktop"]; pending {
t.Error("the desk began a link")
}
@@ -849,9 +848,9 @@ func TestARelinkWithinADayWaitsForTheReplacedAccount(t *testing.T) {
link(h, store, c, "42", c.now().Add(-2*time.Hour))
_, _ = h.Unlink("telegram")
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
h.Link("telegram", ch.LinkAsked{ID: "l9", At: c.now(), Sender: ch.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
h.Link("telegram", oc.LinkAsked{ID: "l9", At: c.now(), Sender: oc.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
code := codeOf(t, dt.sends[len(dt.sends)-1])
a := h.Prove("telegram", ch.Code{Sender: ch.Sender{Identity: "666", Authenticated: true}, Code: code})
a := h.Prove("telegram", oc.Code{Sender: oc.Sender{Identity: "666", Authenticated: true}, Code: code})
if !a.Accepted || !strings.Contains(a.Words, "waits for that account's approval") || len(store.ids) != 0 {
t.Fatalf("linked at once within a day of an unlink: %+v %v", a, store.ids)
}
@@ -979,12 +978,12 @@ func TestAnAnswerNeedsAChannelThatRendersExactly(t *testing.T) {
// At most three links an hour per kind, and the code is shown only where the words stay private.
func TestLinksAreBoundedAndTheCodeShownOnlyInPrivate(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
jo := ch.Sender{Identity: "42", Display: "Jo", Authenticated: true}
jo := oc.Sender{Identity: "42", Display: "Jo", Authenticated: true}
for i := 0; i < LinksAnHour; i++ {
h.Link("telegram", ch.LinkAsked{ID: fmt.Sprint("l", i), At: c.now(), Sender: jo})
h.Link("telegram", oc.LinkAsked{ID: fmt.Sprint("l", i), At: c.now(), Sender: jo})
}
shown := len(dt.sends)
h.Link("telegram", ch.LinkAsked{ID: "l9", At: c.now(), Sender: jo})
h.Link("telegram", oc.LinkAsked{ID: "l9", At: c.now(), Sender: jo})
if len(dt.sends) != shown || !strings.Contains(tg.sends[len(tg.sends)-1].Body, "Try again in an hour") {
t.Error("a fourth link this hour showed a code")
}
@@ -995,7 +994,7 @@ func TestLinksAreBoundedAndTheCodeShownOnlyInPrivate(t *testing.T) {
h2.Bench.SetKinds([]KindInfo{
{Kind: "telegram", Module: "telegram", Capabilities: []string{"choice", "verified-sender", "exact-render", "reaches-away"}},
{Kind: "desktop", Module: "desk-channel", Capabilities: []string{"choice", "exact-render"}}})
h2.Link("telegram", ch.LinkAsked{ID: "l1", At: c2.now(), Sender: jo})
h2.Link("telegram", oc.LinkAsked{ID: "l1", At: c2.now(), Sender: jo})
if _, pending := store2.links["telegram"]; pending || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "No private place") {
t.Error("a code was shown where the words are not private")
}
@@ -1013,7 +1012,7 @@ func TestAWarningTheDeskCouldNotShowGoesAway(t *testing.T) {
if len(dt.sends) != 1 {
t.Fatalf("the desk was sent %d", len(dt.sends))
}
h.Failed("desktop", ch.Failed{Handle: "g14=1", Why: "no session answers", At: c.now()})
h.Failed("desktop", oc.Failed{Handle: "g14=1", Why: "no session answers", At: c.now()})
c.pass(BurstWindow)
h.Flush()
if len(tg.sends) == 0 {
+11 -12
View File
@@ -30,7 +30,6 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
// ErrChanged is a compare-and-set that lost: another writer was first.
@@ -1055,7 +1054,7 @@ func (h *Holder) toldLine(rec AskRecord) string {
// 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 ch.Choice, label, asker string, told bool) {
func (h *Holder) confirmChoice(kind string, c oc.Choice, label, asker string, told bool) {
words := "You chose " + label + ": recorded"
switch {
case asker == SelfAsker:
@@ -1070,7 +1069,7 @@ func (h *Holder) confirmChoice(kind string, c ch.Choice, label, asker string, to
}
// confirmAgain says, in reply to the same answer given again, that it was already recorded.
func (h *Holder) confirmAgain(kind string, c ch.Choice, label string, at time.Time) {
func (h *Holder) confirmAgain(kind string, c oc.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)})
@@ -1174,7 +1173,7 @@ func joinOr(labels []string) string {
// 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 ch.Choice, key string, root func(KindInfo) string) (string, error) {
func (h *Holder) judge(kind string, c oc.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")
@@ -1290,7 +1289,7 @@ func (h *Holder) judge(kind string, c ch.Choice, key string, root func(KindInfo)
// 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 ch.Choice) (string, error) {
func (h *Holder) refuseStranger(kind string, c oc.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})
@@ -1306,7 +1305,7 @@ func (h *Holder) refuseStranger(kind string, c ch.Choice) (string, error) {
}
// refuseChoice says on the kind that an answer was not taken, and why.
func (h *Holder) refuseChoice(kind string, c ch.Choice, why string) (string, error) {
func (h *Holder) refuseChoice(kind string, c oc.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.",
@@ -1326,15 +1325,15 @@ func (h *Holder) showOnHandle(kind string, m Message) (string, error) {
if h.Publish == nil {
return "", errors.New("this router cannot reach the bus")
}
out := ch.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To,
out := oc.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, 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, ch.Button{Label: a.Label, Ticket: a.Token})
out.Buttons = append(out.Buttons, oc.Button{Label: a.Label, Ticket: a.Token})
}
err := h.Publish(ch.ChannelSubject(ch.Show, kind), out, out.Handle)
err := h.Publish(occ.ChannelSubject(occ.Show, kind), out, out.Handle)
h.noteChannel(kind, err)
return out.Handle, err
}
@@ -1351,7 +1350,7 @@ func (h *Holder) showOn(kind string, m Message) error {
if h.Publish == nil {
return errors.New("this router cannot reach the bus")
}
out := ch.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
out := oc.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
if h.Bench != nil && h.Bench.To != nil {
out.To = h.Bench.To(kind)
}
@@ -1360,9 +1359,9 @@ func (h *Holder) showOn(kind string, m Message) error {
}
out.Secret = m.Secret
for _, a := range m.Actions {
out.Buttons = append(out.Buttons, ch.Button{Label: a.Label, Ticket: a.Token})
out.Buttons = append(out.Buttons, oc.Button{Label: a.Label, Ticket: a.Token})
}
err := h.Publish(ch.ChannelSubject(ch.Send, kind), out, out.Handle)
err := h.Publish(occ.ChannelSubject(occ.Send, kind), out, out.Handle)
h.noteChannel(kind, err)
return err
}
+6 -6
View File
@@ -16,8 +16,8 @@ import (
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -98,7 +98,7 @@ func (r *busRuntime) bucket(name string) jetstream.KeyValue {
func (r *busRuntime) setKinds(caps map[string][]string) {
var out []map[string]any
for kind, c := range caps {
out = append(out, map[string]any{"seat": ch.ChannelSeat, "kind": kind, "module": kind, "node": "test", "capabilities": c})
out = append(out, map[string]any{"seat": occ.ChannelSeat, "kind": kind, "module": kind, "node": "test", "capabilities": c})
}
r.mu.Lock()
r.kinds = out
@@ -236,7 +236,7 @@ func (r *busRuntime) runtime(id json.RawMessage, method string, params json.RawM
}
return keys, err
case "mesh/subscribe":
if strings.HasPrefix(p.Pattern, ch.IntakeSeat) {
if strings.HasPrefix(p.Pattern, occ.IntakeSeat) {
r.mu.Lock()
r.subscribed = true
r.mu.Unlock()
@@ -279,7 +279,7 @@ func (r *busRuntime) forward() {
}); err != nil {
r.t.Fatal(err)
}
if _, err := r.nc.Subscribe("mesh.seat."+ch.IntakeSeat+".event.>", func(m *nats.Msg) {
if _, err := r.nc.Subscribe("mesh.seat."+occ.IntakeSeat+".event.>", func(m *nats.Msg) {
key := strings.Replace(strings.TrimPrefix(m.Subject, "mesh.seat."), ".event", "", 1)
_, _ = r.request("mesh/event", map[string]any{"envelope": map[string]any{"key": key, "node": "test", "body": json.RawMessage(m.Data)}})
}); err != nil {
@@ -329,7 +329,7 @@ func TestAnApprovalAskIsHeldThenOfferedWhenAChannelCanCarryItOverTheBus(t *testi
if _, err := r.nc.Subscribe(oc.SubjectDecided("mesh-delivery"), collect(&told)); err != nil {
t.Fatal(err)
}
if _, err := r.nc.Subscribe(ch.ChannelSubject(ch.Show, "telegram"), collect(&shown)); err != nil {
if _, err := r.nc.Subscribe(occ.ChannelSubject(occ.Show, "telegram"), collect(&shown)); err != nil {
t.Fatal(err)
}
r.forward()
@@ -340,7 +340,7 @@ func TestAnApprovalAskIsHeldThenOfferedWhenAChannelCanCarryItOverTheBus(t *testi
return r.subscribed && r.taking && r.kindsRead > 0
})
standing, _ := json.Marshal(Standing{Ready: true, At: time.Now().UTC()})
if err := r.nc.Publish(ch.IntakeSubject(ch.StandingWhat, "telegram"), standing); err != nil {
if err := r.nc.Publish(occ.IntakeSubject(occ.StandingWhat, "telegram"), standing); err != nil {
t.Fatal(err)
}
time.Sleep(time.Second)
@@ -12,7 +12,6 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
"github.com/nats-io/nats.go"
)
@@ -25,7 +24,7 @@ import (
type conditionBed struct {
r *busRuntime
mu sync.Mutex
desk []ch.Message
desk []oc.Message
acks []string // the answer of the router to each work item: "" when acknowledged
}
@@ -40,8 +39,8 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
b := &conditionBed{r: newBusRuntime(t)}
b.r.settings = settings
b.r.setKinds(map[string][]string{"desktop": {"choice", "edit"}})
if _, err := b.r.nc.Subscribe("mesh.seat."+ch.ChannelSeat+".accept.*.desktop", func(m *nats.Msg) {
var msg ch.Message
if _, err := b.r.nc.Subscribe("mesh.seat."+occ.ChannelSeat+".accept.*.desktop", func(m *nats.Msg) {
var msg oc.Message
if json.Unmarshal(m.Data, &msg) == nil {
b.mu.Lock()
b.desk = append(b.desk, msg)
@@ -83,7 +82,7 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
return b.r.subscribed
})
standing, _ := json.Marshal(Standing{Ready: true, At: time.Now().UTC()})
if err := b.r.nc.Publish(ch.IntakeSubject(ch.StandingWhat, "desktop"), standing); err != nil {
if err := b.r.nc.Publish(occ.IntakeSubject(occ.StandingWhat, "desktop"), standing); err != nil {
t.Fatal(err)
}
time.Sleep(time.Second)
@@ -93,7 +92,7 @@ func newConditionBedWith(t *testing.T, settings string) *conditionBed {
// forwardEvents hands the bundle the channels' word, as bus_test.go's forward does for it.
func (b *conditionBed) forwardEvents(t *testing.T) {
t.Helper()
if _, err := b.r.nc.Subscribe("mesh.seat."+ch.IntakeSeat+".event.>", func(m *nats.Msg) {
if _, err := b.r.nc.Subscribe("mesh.seat."+occ.IntakeSeat+".event.>", func(m *nats.Msg) {
key := strings.Replace(strings.TrimPrefix(m.Subject, "mesh.seat."), ".event", "", 1)
_, _ = b.r.request("mesh/event", map[string]any{"envelope": map[string]any{"key": key, "node": "test", "body": json.RawMessage(m.Data)}})
}); err != nil {
@@ -128,10 +127,10 @@ func (b *conditionBed) record(key string) *Record {
return &rec
}
func (b *conditionBed) told() []ch.Message {
func (b *conditionBed) told() []oc.Message {
b.mu.Lock()
defer b.mu.Unlock()
return append([]ch.Message(nil), b.desk...)
return append([]oc.Message(nil), b.desk...)
}
func (b *conditionBed) acknowledged(n int) bool {
+7 -8
View File
@@ -32,7 +32,6 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
const (
@@ -187,7 +186,7 @@ func (h *Holder) offerDeskUnlink(id Identity) bool {
// Link takes a link asked for on a kind: a code shown where only the operator sees it, or a refusal said on
// the kind.
func (h *Holder) Link(kind string, l ch.LinkAsked) string {
func (h *Holder) Link(kind string, l oc.LinkAsked) string {
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
h.work.Lock()
defer h.work.Unlock()
@@ -278,11 +277,11 @@ func (h *Holder) Link(kind string, l ch.LinkAsked) string {
}
// 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 ch.Code) ch.ProofAnswer {
func (h *Holder) Prove(kind string, c oc.Code) oc.ProofAnswer {
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
h.work.Lock()
defer h.work.Unlock()
no := func(words string) ch.ProofAnswer { return ch.ProofAnswer{Accepted: false, Words: words} }
no := func(words string) oc.ProofAnswer { return oc.ProofAnswer{Accepted: false, Words: words} }
if c.Purpose != "" && c.Purpose != "link" {
return no("The mesh takes no code for that yet.")
}
@@ -341,12 +340,12 @@ func (h *Holder) Prove(kind string, c ch.Code) ch.ProofAnswer {
h.announce(Message{Title: "A new " + Display(kind) + " account asks to be linked as you",
Body: fmt.Sprintf("%s (number %s), less than a day after an unlink. It waits for the account it replaces, "+
"or for root at the router's machine.", p.Display, p.Identity), Urgent: true})
return ch.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " +
return oc.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " +
"than a day ago, the link waits for that account's approval, or for you at the router's machine."}
}
h.linkNow(id, "with the code shown on the desk")
from := now.Add(LinkedApproveAfter).In(h.zone()).Format("15:04")
return ch.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
return oc.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
}
// Identities is every linked account, by kind.
@@ -386,7 +385,7 @@ func sixDigits() (string, error) {
}
// decodeLink reads a link asked for.
func decodeLink(body json.RawMessage) (ch.LinkAsked, error) {
var l ch.LinkAsked
func decodeLink(body json.RawMessage) (oc.LinkAsked, error) {
var l oc.LinkAsked
return l, json.Unmarshal(body, &l)
}
+13 -13
View File
@@ -16,9 +16,9 @@ import (
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
@@ -493,7 +493,7 @@ func run(h *Holder, l *listening) {
// 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] != ch.IntakeSeat {
if len(parts) != 3 || parts[0] != occ.IntakeSeat {
return "", "", false
}
return parts[1], parts[2], true
@@ -508,8 +508,8 @@ func listenForChannels(h *Holder) {
return nil
}
switch what {
case ch.Chosen:
var c ch.Choice
case occ.Chosen:
var c oc.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
@@ -518,19 +518,19 @@ func listenForChannels(h *Holder) {
c.ID = e.Headers["x-event-id"]
}
_, _ = h.Chosen(kind, c)
case ch.Link:
case occ.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 ch.FailedWhat:
var f ch.Failed
case occ.FailedWhat:
var f oc.Failed
if json.Unmarshal(e.Body, &f) == nil {
h.Failed(kind, f)
}
case ch.StandingWhat:
case occ.StandingWhat:
// Read as this module's Standing: the bench's word, and answers-lost-at beside it (issue 384).
var s Standing
if json.Unmarshal(e.Body, &s) == nil {
@@ -541,7 +541,7 @@ func listenForChannels(h *Holder) {
return nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.Subscribe(ch.IntakeSeat+".#", handle); err == nil {
if err := stdio.Subscribe(occ.IntakeSeat+".#", handle); err == nil {
logf("[messenger] listening for what the channels say")
break
} else {
@@ -580,14 +580,14 @@ func answerProofs(h *Holder) {
handle := func(subject string, body json.RawMessage) (any, error) {
parts := strings.Split(subject, ".")
kind := parts[len(parts)-1]
var c ch.Code
var c oc.Code
if err := json.Unmarshal(body, &c); err != nil {
return ch.ProofAnswer{Words: "The code could not be read."}, nil
return oc.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(ch.ProofSubject(ch.CodeProof, "*"), handle); err == nil {
if err := stdio.SeatAnswer(occ.ProofSubject(occ.CodeProof, "*"), handle); err == nil {
logf("[messenger] answering the codes the channels are sent")
break
} else {
@@ -614,7 +614,7 @@ func followKinds(h *Holder) {
} else {
var out []KindInfo
for _, k := range kinds {
if k.Seat == ch.ChannelSeat {
if k.Seat == occ.ChannelSeat {
out = append(out, KindInfo{Kind: k.Kind, Module: k.Module, Node: k.Node, Capabilities: k.Capabilities})
}
}
@@ -9,8 +9,8 @@ import (
"strings"
"testing"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
ch "git.novox.be/novox/mesh-sdk/go/channel"
)
// The manifest says what the code does (novox/hq ADR 0259): the seats it declares — the operator's, and
@@ -72,9 +72,9 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
want := []seat{
{Name: oc.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided"},
ByCaller: []string{"ask", "cancel", "condition", "decided"}, Records: []string{"asks"}},
{Name: ch.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{ch.Show, ch.Edit, ch.Send}},
{Name: ch.IntakeSeat, Scope: "mesh", Kinded: true,
Emits: []string{ch.Chosen, ch.Link, ch.FailedWhat, ch.StandingWhat}, Proofs: []string{ch.CodeProof}},
{Name: occ.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{occ.Show, occ.Edit, occ.Send}},
{Name: occ.IntakeSeat, Scope: "mesh", Kinded: true,
Emits: []string{occ.Chosen, occ.Link, occ.FailedWhat, occ.StandingWhat}, Proofs: []string{occ.CodeProof}},
}
if !reflect.DeepEqual(m.Seats, want) {
t.Fatalf("seats %+v", m.Seats)
@@ -82,7 +82,7 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
if len(m.Claims) != 1 || m.Claims[0].Name != oc.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) {
t.Fatalf("claims %+v", m.Claims)
}
if !reflect.DeepEqual(m.Uses, []string{ch.ChannelSeat}) {
if !reflect.DeepEqual(m.Uses, []string{occ.ChannelSeat}) {
t.Fatalf("uses %v", m.Uses)
}
if !reflect.DeepEqual(m.Consumes, []string{
+18 -19
View File
@@ -17,7 +17,6 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
chn "git.novox.be/novox/mesh-sdk/go/channel"
)
// H3: approval rests on the controller's positive, recent word that no agent can become root where the router
@@ -206,11 +205,11 @@ func TestALinksCodeIsSecretOnEveryPath(t *testing.T) {
bench.Stand("telegram", Standing{Ready: true, At: c.now()})
bench.Stand("desktop", Standing{Ready: true, At: c.now()})
var mu sync.Mutex
put := map[string][]chn.Message{}
put := map[string][]oc.Message{}
publish := func(subject string, body any, id string) error {
mu.Lock()
defer mu.Unlock()
if m, ok := body.(chn.Message); ok {
if m, ok := body.(oc.Message); ok {
put[subject] = append(put[subject], m)
}
return nil
@@ -219,8 +218,8 @@ func TestALinksCodeIsSecretOnEveryPath(t *testing.T) {
Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, Bench: bench, Publish: publish,
Asks: newMemAsks(), Node: "anchor", Now: c.now, Logf: t.Logf, Zone: func() *time.Location { return time.UTC }}
h.init()
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
shown := put[chn.ChannelSubject(chn.Send, "desktop")]
h.Link("telegram", oc.LinkAsked{ID: "l1", At: c.now(), Sender: oc.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
shown := put[occ.ChannelSubject(occ.Send, "desktop")]
if len(shown) != 1 || !strings.Contains(shown[0].Body, "send this code") {
t.Fatalf("the code was not shown on the desk: %+v", put)
}
@@ -307,9 +306,9 @@ func TestALinkIsNotMadeWhileAnotherAccountIsLinked(t *testing.T) {
link(h, store, c, "42", c.now().Add(-2*time.Hour))
_, _ = h.Unlink("telegram")
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
h.Link("telegram", chn.LinkAsked{ID: "l9", At: c.now(), Sender: chn.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
h.Link("telegram", oc.LinkAsked{ID: "l9", At: c.now(), Sender: oc.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
code := codeOf(t, dt.sends[len(dt.sends)-1])
_ = h.Prove("telegram", chn.Code{Sender: chn.Sender{Identity: "666", Authenticated: true}, Code: code})
_ = h.Prove("telegram", oc.Code{Sender: oc.Sender{Identity: "666", Authenticated: true}, Code: code})
asked := tg.sends[len(tg.sends)-1]
// Meanwhile root linked another account (allowed at the machine).
store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Linked: c.now(), ApproveFrom: c.now().Add(time.Hour)}
@@ -326,7 +325,7 @@ func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) {
_, _ = h.Unlink("telegram")
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
shown := len(dt.sends)
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
h.Link("telegram", oc.LinkAsked{ID: "l1", At: c.now(), Sender: oc.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if _, pending := store.links["telegram"]; pending {
t.Error("a link was begun for the account unlinked an hour ago")
}
@@ -345,7 +344,7 @@ func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) {
func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
h, tg, dt, c, store, w := newRouter(t)
link(h, store, c, "666", c.now().Add(-10*time.Minute)) // an account the operator did not link
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
h.Link("telegram", oc.LinkAsked{ID: "l1", At: c.now(), Sender: oc.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if reply := tg.sends[len(tg.sends)-1]; !strings.Contains(reply.Body, "unlink it at your desk") {
t.Fatalf("the sender was not told how: %q", reply.Body)
}
@@ -367,7 +366,7 @@ func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
// Past its hour, no question goes to the desk.
h2, tg2, dt2, c2, store2, _ := newRouter(t)
link(h2, store2, c2, "666", c2.now().Add(-2*time.Hour))
h2.Link("telegram", chn.LinkAsked{ID: "l1", At: c2.now(), Sender: chn.Sender{Identity: "42", Authenticated: true}})
h2.Link("telegram", oc.LinkAsked{ID: "l1", At: c2.now(), Sender: oc.Sender{Identity: "42", Authenticated: true}})
for _, m := range dt2.sends {
if labelsOf(m) == "Unlink it" {
t.Error("the desk was asked to unlink an account past its first hour")
@@ -383,7 +382,7 @@ func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
h.Bench.Stand("desktop", Standing{Ready: false, Why: "notifications are paused on the desk", At: c.now()})
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
h.Link("telegram", oc.LinkAsked{ID: "l1", At: c.now(), Sender: oc.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if _, pending := store.links["telegram"]; pending {
t.Error("a link was begun")
}
@@ -399,12 +398,12 @@ func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) {
// operator's link, and a restart forgets no count.
func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
eve := chn.Sender{Identity: "666", Display: "Eve", Authenticated: true}
eve := oc.Sender{Identity: "666", Display: "Eve", Authenticated: true}
for i := 0; i < LinksAnHour; i++ {
h.Link("telegram", chn.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve})
h.Link("telegram", oc.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve})
}
shown := len(dt.sends)
h.Link("telegram", chn.LinkAsked{ID: "jo", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
h.Link("telegram", oc.LinkAsked{ID: "jo", At: c.now(), Sender: oc.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if len(dt.sends) != shown+1 || !strings.Contains(dt.sends[len(dt.sends)-1].Body, "(number 42)") {
t.Fatalf("a stranger's tries stopped the operator's link: %q", tg.sends[len(tg.sends)-1].Body)
}
@@ -414,7 +413,7 @@ func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) {
c2.pass(c.now().Sub(c2.now()))
stand(h2, c2)
before := len(dt2.sends)
h2.Link("telegram", chn.LinkAsked{ID: "e9", At: c2.now(), Sender: eve})
h2.Link("telegram", oc.LinkAsked{ID: "e9", At: c2.now(), Sender: eve})
if len(dt2.sends) != before || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "Try again in an hour") {
t.Error("a restart forgot the count")
}
@@ -463,7 +462,7 @@ func TestTwoAnswersAtOnceMakeOneWarrant(t *testing.T) {
func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-10*time.Minute)) // the operator's own, linked just now
h.Link("telegram", chn.LinkAsked{ID: "l1", At: c.now(), Sender: chn.Sender{Identity: "666", Display: "X", Authenticated: true}})
h.Link("telegram", oc.LinkAsked{ID: "l1", At: c.now(), Sender: oc.Sender{Identity: "666", Display: "X", Authenticated: true}})
asked := dt.sends[len(dt.sends)-1]
ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now()) // a click nobody can prove was the operator's
ch.Sender.Authenticated = false
@@ -479,7 +478,7 @@ func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
// The operator sends /start again: a code is shown at once, and the code links without waiting for anybody.
c.pass(time.Minute)
stand(h, c)
h.Link("telegram", chn.LinkAsked{ID: "l2", At: c.now(), Sender: chn.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
h.Link("telegram", oc.LinkAsked{ID: "l2", At: c.now(), Sender: oc.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
code := ""
for _, m := range dt.sends {
if strings.Contains(m.Body, "number 42") && strings.Contains(m.Body, "send this code") {
@@ -489,7 +488,7 @@ func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
if code == "" {
t.Fatalf("no code was shown to relink the operator's account; said %q", tg.sends[len(tg.sends)-1].Body)
}
if a := h.Prove("telegram", chn.Code{Sender: chn.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}); !a.Accepted ||
if a := h.Prove("telegram", oc.Code{Sender: oc.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}); !a.Accepted ||
!strings.Contains(a.Words, "Linked") {
t.Fatalf("the operator's code was not taken at once: %+v", a)
}
@@ -549,7 +548,7 @@ func TestTheRootFreeWordIsNotAskedWhileTheWorkIsHeld(t *testing.T) {
<-asking
linked := make(chan struct{})
go func() {
h.Link("telegram", chn.LinkAsked{ID: "l9", At: c.now(), Sender: chn.Sender{Identity: "77", Authenticated: true}})
h.Link("telegram", oc.LinkAsked{ID: "l9", At: c.now(), Sender: oc.Sender{Identity: "77", Authenticated: true}})
close(linked)
}()
select {
@@ -24,7 +24,8 @@ import (
"sync"
"time"
ch "git.novox.be/novox/mesh-sdk/go/channel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// StandingFresh is how long a channel's word on whether it can send is believed: a channel says it at
@@ -277,16 +278,16 @@ 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 := ch.Send
verb := occ.Send
switch {
case edit:
verb = ch.Edit
verb = occ.Edit
case len(m.Actions) > 0:
verb = ch.Show
verb = occ.Show
}
// Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it
// dropped here, where the desk's own role sends).
out := ch.Message{Handle: handle, Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
out := oc.Message{Handle: handle, Title: m.Title, Body: m.Body, Note: m.Note, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
Secret: m.Secret}
if c.Bench.To != nil {
out.To = c.Bench.To(kind)
@@ -295,9 +296,9 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
out.To = m.To
}
for _, a := range m.Actions {
out.Buttons = append(out.Buttons, ch.Button{Label: a.Label, Ticket: a.Token})
out.Buttons = append(out.Buttons, oc.Button{Label: a.Label, Ticket: a.Token})
}
return c.Publish(ch.ChannelSubject(verb, kind), out, handle+"."+fmt.Sprint(time.Now().UnixNano()))
return c.Publish(occ.ChannelSubject(verb, kind), out, handle+"."+fmt.Sprint(time.Now().UnixNano()))
}
func newHandle() string {
+22 -21
View File
@@ -39,7 +39,8 @@ import (
"time"
"unicode/utf8"
"git.novox.be/novox/mesh-sdk/go/channel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// Kind is this channel's kind on both benches.
@@ -134,7 +135,7 @@ type Channel struct {
meAt time.Time
me string
meErr string
said channel.Standing
said oc.Standing
saidAt time.Time
lastSent time.Time
lastErr string
@@ -167,7 +168,7 @@ func (c *Channel) init() {
// buttons are a message's buttons as the API takes them: a label and the ticket, which must fit the 64
// bytes Telegram allows; one that does not is left out and said.
func (c *Channel) buttons(m channel.Message) [][2]string {
func (c *Channel) buttons(m oc.Message) [][2]string {
var out [][2]string
for _, b := range m.Buttons {
if len(b.Ticket) == 0 || len(b.Ticket) > 64 {
@@ -181,7 +182,7 @@ func (c *Channel) buttons(m channel.Message) [][2]string {
// chatFor is where a message goes: the linked account the router names, else the chat of the arrival it
// replies to. Neither is a permanent failure: nobody is linked, and nobody asked.
func (c *Channel) chatFor(m channel.Message) (int64, error) {
func (c *Channel) chatFor(m oc.Message) (int64, error) {
if m.To != "" {
id, err := strconv.ParseInt(m.To, 10, 64)
if err != nil {
@@ -208,14 +209,14 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
return nil
}
verb := parts[4]
var m channel.Message
var m oc.Message
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
c.Logf("[telegram] work on %s could not be read; taken and dropped", subject)
return nil
}
// The router's word on a tap, said where the tap was made: the tap's own acknowledgement (issue 372). A
// word of one line is that alone; one with more to it is also said in the chat, where it stays.
if verb == channel.Send && m.Reply != "" && !m.Secret && c.settleTap(m.Reply, toast(m)) && strings.TrimSpace(m.Body) == "" {
if verb == occ.Send && m.Reply != "" && !m.Secret && c.settleTap(m.Reply, toast(m)) && strings.TrimSpace(m.Body) == "" {
c.mu.Lock()
c.lastSent, c.lastErr = c.Now(), ""
c.delivered++
@@ -235,7 +236,7 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
// delete loses its buttons and says a newer message follows. A send that fails leaves the earlier one.
var place, earlier *Placed
switch {
case verb == channel.Edit:
case verb == occ.Edit:
if place, err = c.Store.Placed(m.Handle); err != nil {
return err // the state is away: offered again
}
@@ -320,8 +321,8 @@ func (c *Channel) fail(handle string, err error, forGood bool) {
c.failed++
c.mu.Unlock()
c.Logf("[telegram] a message was not delivered: %v", err)
f := channel.Failed{Handle: handle, Why: err.Error(), Permanent: forGood, At: c.Now()}
if perr := c.Publish(channel.IntakeSubject(channel.FailedWhat, Kind), f, "failed."+handle+"."+strconv.FormatInt(c.Now().UnixNano(), 10)); perr != nil {
f := oc.Failed{Handle: handle, Why: err.Error(), Permanent: forGood, At: c.Now()}
if perr := c.Publish(occ.IntakeSubject(occ.FailedWhat, Kind), f, "failed."+handle+"."+strconv.FormatInt(c.Now().UnixNano(), 10)); perr != nil {
c.Logf("[telegram] and the router could not be told: %v", perr)
}
}
@@ -332,8 +333,8 @@ func private(from *User, chat Chat) bool {
return from != nil && !from.IsBot && chat.Type == "private" && chat.ID == from.ID
}
func senderOf(u User) channel.Sender {
return channel.Sender{Identity: strconv.FormatInt(u.ID, 10), Display: u.Display(), Authenticated: true}
func senderOf(u User) oc.Sender {
return oc.Sender{Identity: strconv.FormatInt(u.ID, 10), Display: u.Display(), Authenticated: true}
}
// Handle takes one update.
@@ -368,8 +369,8 @@ func (c *Channel) Handle(u Update) {
c.mu.Lock()
c.taps[id] = cb.ID
c.mu.Unlock()
ch := channel.Choice{ID: id, Ticket: cb.Data, Sender: senderOf(cb.From), At: now}
if err := c.Publish(channel.IntakeSubject(channel.Chosen, Kind), ch, id); err != nil {
ch := oc.Choice{ID: id, Ticket: cb.Data, Sender: senderOf(cb.From), At: now}
if err := c.Publish(occ.IntakeSubject(occ.Chosen, Kind), ch, id); err != nil {
c.Logf("[telegram] a tap could not be said to the router: %v", err)
words := "The mesh could not take this answer now. Nothing was done. Try again in a minute."
c.settleTap(id, words)
@@ -386,8 +387,8 @@ func (c *Channel) Handle(u Update) {
c.remember(id, m.Chat.ID)
switch t := strings.TrimSpace(m.Text); {
case t == "/start" || strings.HasPrefix(t, "/start "):
l := channel.LinkAsked{ID: id, Sender: senderOf(*m.From), At: now}
if err := c.Publish(channel.IntakeSubject(channel.Link, Kind), l, id); err != nil {
l := oc.LinkAsked{ID: id, Sender: senderOf(*m.From), At: now}
if err := c.Publish(occ.IntakeSubject(occ.Link, Kind), l, id); err != nil {
c.reply(m.Chat.ID, "The mesh could not take this now. Try again in a minute.")
}
case codeShape.MatchString(t):
@@ -396,12 +397,12 @@ func (c *Channel) Handle(u Update) {
if err := c.Bot.Delete(m.Chat.ID, m.MessageID); err != nil {
c.Logf("[telegram] a code could not be deleted from the chat: %v", err)
}
raw, err := c.Prove(channel.ProofSubject(channel.CodeProof, Kind), channel.Code{Sender: senderOf(*m.From), Code: code, Purpose: "link"})
raw, err := c.Prove(occ.ProofSubject(occ.CodeProof, Kind), oc.Code{Sender: senderOf(*m.From), Code: code, Purpose: "link"})
if err != nil {
c.reply(m.Chat.ID, "The mesh could not check the code now. Try again in a minute.")
return
}
var a channel.ProofAnswer
var a oc.ProofAnswer
if json.Unmarshal(raw, &a) != nil || a.Words == "" {
a.Words = "The mesh's answer could not be read."
}
@@ -432,7 +433,7 @@ func (c *Channel) settleTap(arrival, words string) bool {
// toast is a message as a tap's acknowledgement shows it: its title and the first line of its body, cut at
// ToastLength.
func toast(m channel.Message) string {
func toast(m oc.Message) string {
t := strings.TrimSpace(m.Title)
if first, _, _ := strings.Cut(strings.TrimSpace(m.Body), "\n"); first != "" {
t += ": " + first
@@ -502,10 +503,10 @@ func (c *Channel) Poll(wait time.Duration) error {
}
// Standing is whether the channel can send now: the token works and the updates arrive.
func (c *Channel) Standing() channel.Standing {
func (c *Channel) Standing() oc.Standing {
c.init()
now := c.Now()
s := channel.Standing{EditsSilently: true, At: now}
s := oc.Standing{EditsSilently: true, At: now}
if _, err := c.Bot.token(); err != nil {
s.Why = err.Error()
return s
@@ -545,7 +546,7 @@ func (c *Channel) SayStanding() {
if same {
return
}
if err := c.Publish(channel.IntakeSubject(channel.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
if err := c.Publish(occ.IntakeSubject(occ.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
c.Logf("[telegram] could not say whether it can send: %v", err)
return
}
+50 -49
View File
@@ -15,7 +15,8 @@ import (
"time"
"unicode/utf8"
"git.novox.be/novox/mesh-sdk/go/channel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
const goodToken = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
@@ -184,15 +185,15 @@ func (s *said) waited() {
}
}
func work(verb string, m channel.Message) (string, json.RawMessage) {
func work(verb string, m oc.Message) (string, json.RawMessage) {
raw, _ := json.Marshal(m)
return channel.ChannelSubject(verb, Kind), raw
return occ.ChannelSubject(verb, Kind), raw
}
func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T) {
c, f, _, st := newChannel(t, goodToken)
subject, body := work(channel.Show, channel.Message{Handle: "m1", Title: "Delivery of hq held", Body: "Needs you: release it.",
To: "42", Buttons: []channel.Button{{Label: "Release", Ticket: "0123456789abcdef0123456789abcdef"}}})
subject, body := work(occ.Show, oc.Message{Handle: "m1", Title: "Delivery of hq held", Body: "Needs you: release it.",
To: "42", Buttons: []oc.Button{{Label: "Release", Ticket: "0123456789abcdef0123456789abcdef"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
@@ -212,12 +213,12 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T)
if st.placed["m1"].ID == 0 {
t.Error("where it is was not kept")
}
subject, body = work(channel.Send, channel.Message{Handle: "m2", Title: "Resolved: hq works again", To: "42"})
subject, body = work(occ.Send, oc.Message{Handle: "m2", Title: "Resolved: hq works again", To: "42"})
_ = c.Take(subject, body)
if f.of("sendMessage")[1].Body["disable_notification"] != true {
t.Error("a clearing rang (D4)")
}
subject, body = work(channel.Send, channel.Message{Handle: "m3", Title: "Urgent: ace silent", Urgent: true, To: "42"})
subject, body = work(occ.Send, oc.Message{Handle: "m3", Title: "Urgent: ace silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
if f.of("sendMessage")[2].Body["disable_notification"] != false {
t.Error("an urgent message was silent (D4)")
@@ -226,7 +227,7 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T)
func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
c, f, _, _ := newChannel(t, goodToken)
subject, body := work(channel.Send, channel.Message{Handle: "m1", Title: "t", Body: strings.Repeat("x", 5000), To: "42"})
subject, body := work(occ.Send, oc.Message{Handle: "m1", Title: "t", Body: strings.Repeat("x", 5000), To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
@@ -243,10 +244,10 @@ func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
// nor injects. The tap on it is still compared by the plain words, which is what Telegram gives back.
func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
subject, body := work(channel.Show, channel.Message{Handle: "q1", Title: "Set mounts on <shanks> & co?",
subject, body := work(occ.Show, oc.Message{Handle: "q1", Title: "Set mounts on <shanks> & co?",
Body: "Changes two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)",
Note: "Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.",
To: "42", Buttons: []channel.Button{{Label: "Approve", Ticket: "tkt"}}})
To: "42", Buttons: []oc.Button{{Label: "Approve", Ticket: "tkt"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
@@ -264,7 +265,7 @@ func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
Text: "Set mounts on <shanks> & co?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 1 {
if got := s.of(occ.IntakeSubject(occ.Chosen, Kind)); len(got) != 1 {
t.Fatalf("the tap on the rendered message was not taken: %s", got)
}
// Words changed by another holder of the token are still told apart.
@@ -272,11 +273,11 @@ func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
Text: "Set mounts on <shanks> & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 1 {
if got := s.of(occ.IntakeSubject(occ.Chosen, Kind)); len(got) != 1 {
t.Fatalf("a tap on changed words was taken: %s", got)
}
// The edit says what was chosen, one line with the headline, and is in the same mode.
subject, body = work(channel.Edit, channel.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?",
subject, body = work(occ.Edit, oc.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?",
Note: "Told to mesh-controller, which acts on it itself.", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
@@ -302,7 +303,7 @@ func TestATapOnAMessageShownBeforeIsStillTaken(t *testing.T) {
}
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: 9, Chat: Chat{ID: 42, Type: "private"}, Text: words}}})
if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 1 {
if got := s.of(occ.IntakeSubject(occ.Chosen, Kind)); len(got) != 1 {
t.Fatalf("the tap on a message shown before was not taken: %s", got)
}
}
@@ -313,8 +314,8 @@ func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) {
f.answer["editMessageText"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message is not modified"}`
}
subject, body := work(channel.Edit, channel.Message{Handle: "m1", Title: "same", To: "42"})
if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 0 || len(s.of(channel.IntakeSubject(channel.FailedWhat, Kind))) != 0 {
subject, body := work(occ.Edit, oc.Message{Handle: "m1", Title: "same", To: "42"})
if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 0 || len(s.of(occ.IntakeSubject(occ.FailedWhat, Kind))) != 0 {
t.Fatalf("an identical edit was not a success (D6): %v", err)
}
f.answer["editMessageText"] = func(map[string]any) (int, string) {
@@ -341,21 +342,21 @@ func TestARefusalIsPermanentARateLimitIsWaitedOutAndNeitherCarriesTheToken(t *te
}
return 403, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user ` + goodToken + `"}`
}
subject, body := work(channel.Send, channel.Message{Handle: "m1", Title: "x", To: "42"})
subject, body := work(occ.Send, oc.Message{Handle: "m1", Title: "x", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
if len(slept) != 1 || slept[0] != 7*time.Second || tries != 2 {
t.Errorf("the rate limit was not waited out as told (D5): slept %v, tries %d", slept, tries)
}
failed := s.of(channel.IntakeSubject(channel.FailedWhat, Kind))
failed := s.of(occ.IntakeSubject(occ.FailedWhat, Kind))
if len(failed) != 1 || !strings.Contains(string(failed[0]), `"permanent":true`) || strings.Contains(string(failed[0]), goodToken) {
t.Fatalf("the refusal: %s (D6)", failed)
}
// No linked account and nothing to reply to: said as failed for good, never retried.
subject, body = work(channel.Send, channel.Message{Handle: "m2", Title: "x"})
subject, body = work(occ.Send, oc.Message{Handle: "m2", Title: "x"})
_ = c.Take(subject, body)
if f := s.of(channel.IntakeSubject(channel.FailedWhat, Kind)); len(f) != 2 || !strings.Contains(string(f[1]), "no account is linked") {
if f := s.of(occ.IntakeSubject(occ.FailedWhat, Kind)); len(f) != 2 || !strings.Contains(string(f[1]), "no account is linked") {
t.Errorf("%s", f)
}
}
@@ -365,10 +366,10 @@ func TestATapIsAcknowledgedAtOnceAndSaidWithItsAuthenticatedSender(t *testing.T)
shown := showQuestion(t, c, f)
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Release it?\nThe delivery is held."}}})
got := s.of(channel.IntakeSubject(channel.Chosen, Kind))
var ch channel.Choice
got := s.of(occ.IntakeSubject(occ.Chosen, Kind))
var ch oc.Choice
if len(got) != 1 || json.Unmarshal(got[0], &ch) != nil || ch.Ticket != "tkt" ||
!reflect.DeepEqual(ch.Sender, channel.Sender{Identity: "42", Display: "Jo", Authenticated: true}) {
!reflect.DeepEqual(ch.Sender, oc.Sender{Identity: "42", Display: "Jo", Authenticated: true}) {
t.Fatalf("said %s", got)
}
// In a group, or from another bot, nothing is said: it is nobody's own chat with the bot.
@@ -376,7 +377,7 @@ func TestATapIsAcknowledgedAtOnceAndSaidWithItsAuthenticatedSender(t *testing.T)
Message: &Msg{Chat: Chat{ID: -100, Type: "group"}}}})
c.Handle(Update{UpdateID: 3, Callback: &Callback{ID: "cb3", Data: "tkt", From: User{ID: 9, IsBot: true},
Message: &Msg{Chat: Chat{ID: 9, Type: "private"}}}})
if len(s.of(channel.IntakeSubject(channel.Chosen, Kind))) != 1 {
if len(s.of(occ.IntakeSubject(occ.Chosen, Kind))) != 1 {
t.Error("a tap outside the account's own chat was said")
}
}
@@ -385,13 +386,13 @@ func TestStartAsksALinkAndACodeIsAProofDeletedFromTheChat(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
jo := &User{ID: 42, FirstName: "Jo", Username: "jo"}
c.Handle(Update{UpdateID: 10, Message: &Msg{MessageID: 5, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "/start"}})
var l channel.LinkAsked
got := s.of(channel.IntakeSubject(channel.Link, Kind))
var l oc.LinkAsked
got := s.of(occ.IntakeSubject(occ.Link, Kind))
if len(got) != 1 || json.Unmarshal(got[0], &l) != nil || l.Sender.Identity != "42" || l.Sender.Display != "Jo (@jo)" {
t.Fatalf("link %s", got)
}
// The router's answer to the link goes to the chat it came from, though nobody is linked yet.
subject, body := work(channel.Send, channel.Message{Handle: "r1", Title: "Send the code shown on your desk", Reply: l.ID})
subject, body := work(occ.Send, oc.Message{Handle: "r1", Title: "Send the code shown on your desk", Reply: l.ID})
if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 1 || f.of("sendMessage")[0].Body["chat_id"] != float64(42) {
t.Fatalf("the reply did not reach the chat: %v %v", err, f.of("sendMessage"))
}
@@ -439,7 +440,7 @@ func TestStandingSaysWhatIsMissingAndNeverTheToken(t *testing.T) {
}
c.SayStanding()
c.SayStanding()
if n := len(said.of(channel.IntakeSubject(channel.StandingWhat, Kind))); n != 1 {
if n := len(said.of(occ.IntakeSubject(occ.StandingWhat, Kind))); n != 1 {
t.Errorf("standing said %d times without a change", n)
}
}
@@ -449,7 +450,7 @@ func TestPollTakesTheUpdatesAndKeepsTheOffsetAndSaysAConflict(t *testing.T) {
f.answer["getUpdates"] = func(map[string]any) (int, string) {
return 200, `{"ok":true,"result":[{"update_id":41,"message":{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"text":"/start"}}]}`
}
if err := c.Poll(time.Second); err != nil || st.offset != 42 || len(s.of(channel.IntakeSubject(channel.Link, Kind))) != 1 {
if err := c.Poll(time.Second); err != nil || st.offset != 42 || len(s.of(occ.IntakeSubject(occ.Link, Kind))) != 1 {
t.Fatalf("%v offset %d", err, st.offset)
}
f.answer["getUpdates"] = func(map[string]any) (int, string) {
@@ -466,8 +467,8 @@ func TestPollTakesTheUpdatesAndKeepsTheOffsetAndSaysAConflict(t *testing.T) {
// showQuestion shows one question with a button to the linked account, and answers its message's id.
func showQuestion(t *testing.T, c *Channel, f *fakeAPI) int64 {
t.Helper()
subject, body := work(channel.Show, channel.Message{Handle: "q1", Title: "Release it?", Body: "The delivery is held.",
To: "42", Buttons: []channel.Button{{Label: "Release", Ticket: "tkt"}}})
subject, body := work(occ.Show, oc.Message{Handle: "q1", Title: "Release it?", Body: "The delivery is held.",
To: "42", Buttons: []oc.Button{{Label: "Release", Ticket: "tkt"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
@@ -493,7 +494,7 @@ func TestATapOnAMessageShowingOtherWordsIsRefused(t *testing.T) {
}
tap("cb-other", shown, "Silence it for a week?\nNothing changes.")
tap("cb-unknown", shown+100, "Release it?\nThe delivery is held.")
if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 0 {
if got := s.of(occ.IntakeSubject(occ.Chosen, Kind)); len(got) != 0 {
t.Fatalf("a tap on other words was said: %s", got)
}
answers := f.of("answerCallbackQuery")
@@ -502,16 +503,16 @@ func TestATapOnAMessageShowingOtherWordsIsRefused(t *testing.T) {
}
// The same words, as Telegram gives them back (its line endings, its outer spaces), are taken.
tap("cb-same", shown, " Release it?\r\nThe delivery is held. ")
if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 1 {
if got := s.of(occ.IntakeSubject(occ.Chosen, Kind)); len(got) != 1 {
t.Fatalf("the tap on the words shown was not said: %s", got)
}
// An edit moves what was shown: the old words are then refused.
subject, body := work(channel.Edit, channel.Message{Handle: "q1", Title: "Released", Body: "by you", To: "42"})
subject, body := work(occ.Edit, oc.Message{Handle: "q1", Title: "Released", Body: "by you", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
tap("cb-old", shown, "Release it?\nThe delivery is held.")
if got := s.of(channel.IntakeSubject(channel.Chosen, Kind)); len(got) != 1 {
if got := s.of(occ.IntakeSubject(occ.Chosen, Kind)); len(got) != 1 {
t.Errorf("a tap on words since replaced was said")
}
if d, _ := st.Shown(42, shown); d == "" {
@@ -536,7 +537,7 @@ func TestATapIsAcknowledgedWithTheRoutersWordOnIt(t *testing.T) {
t.Fatalf("the tap was acknowledged before the router said anything: %v", got)
}
sentBefore := len(f.of("sendMessage"))
subject, body := work(channel.Send, channel.Message{Handle: "r1", Title: "You chose Release: recorded, and mesh-delivery was told.",
subject, body := work(occ.Send, oc.Message{Handle: "r1", Title: "You chose Release: recorded, and mesh-delivery was told.",
Silent: true, Reply: "tg-tap-cb1", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
@@ -569,7 +570,7 @@ func TestARefusedTapSaysWhyOnThePhoneAndInTheChat(t *testing.T) {
shown := showQuestion(t, c, f)
tapOn(c, shown, "cb1")
sentBefore := len(f.of("sendMessage"))
subject, body := work(channel.Send, channel.Message{Handle: "r1", Title: "That answer was not taken",
subject, body := work(occ.Send, oc.Message{Handle: "r1", Title: "That answer was not taken",
Body: "The question was already answered or has ended. Nothing was done.", Silent: true, Reply: "tg-tap-cb1", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
@@ -595,7 +596,7 @@ func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T
t.Fatalf("a slow router's tap was acknowledged with %v", got)
}
sentBefore := len(f.of("sendMessage"))
subject, body := work(channel.Send, channel.Message{Handle: "r1", Title: "You chose Release: recorded.", Reply: "tg-tap-cb1", To: "42"})
subject, body := work(occ.Send, oc.Message{Handle: "r1", Title: "You chose Release: recorded.", Reply: "tg-tap-cb1", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
@@ -607,7 +608,7 @@ func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: query is too old and response timeout expired or query ID is invalid"}`
}
tapOn(c, shown, "cb2")
subject, body = work(channel.Send, channel.Message{Handle: "r2", Title: "You chose Release: recorded.", Reply: "tg-tap-cb2", To: "42"})
subject, body = work(occ.Send, oc.Message{Handle: "r2", Title: "You chose Release: recorded.", Reply: "tg-tap-cb2", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
@@ -629,7 +630,7 @@ func TestATapNotSaidToTheRouterIsSaidSoAndASecretIsNeverAToast(t *testing.T) {
}
c.Publish = func(string, any, string) error { return nil }
tapOn(c, shown, "cb2")
subject, body := work(channel.Send, channel.Message{Handle: "r1", Title: "Your code", Body: "123456", Secret: true,
subject, body := work(occ.Send, oc.Message{Handle: "r1", Title: "Your code", Body: "123456", Secret: true,
Reply: "tg-tap-cb2", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
@@ -643,11 +644,11 @@ func TestATapNotSaidToTheRouterIsSaidSoAndASecretIsNeverAToast(t *testing.T) {
// The toast is the title and the first line of the body, cut at Telegram's bound.
func TestAToastIsCutAtTelegramsBound(t *testing.T) {
long := toast(channel.Message{Title: strings.Repeat("a", 300)})
long := toast(oc.Message{Title: strings.Repeat("a", 300)})
if utf8.RuneCountInString(long) != ToastLength || !strings.HasSuffix(long, "…") {
t.Errorf("a long toast is %d runes", utf8.RuneCountInString(long))
}
if got := toast(channel.Message{Title: "Details: x", Body: "first\nsecond"}); got != "Details: x: first" {
if got := toast(oc.Message{Title: "Details: x", Body: "first\nsecond"}); got != "Details: x: first" {
t.Errorf("toast %q", got)
}
}
@@ -657,19 +658,19 @@ func TestAToastIsCutAtTelegramsBound(t *testing.T) {
// loses its buttons. A new handle is a new message, and nothing is deleted.
func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
c, f, _, st := newChannel(t, goodToken)
subject, body := work(channel.Show, channel.Message{Handle: "m1", Title: "Urgent: ace silent", Urgent: true, To: "42",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "0123456789abcdef0123456789abcdef"}}})
subject, body := work(occ.Show, oc.Message{Handle: "m1", Title: "Urgent: ace silent", Urgent: true, To: "42",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "0123456789abcdef0123456789abcdef"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
first := st.placed["m1"].ID
subject, body = work(channel.Send, channel.Message{Handle: "m2", Title: "another problem", To: "42"})
subject, body = work(occ.Send, oc.Message{Handle: "m2", Title: "another problem", To: "42"})
_ = c.Take(subject, body)
if len(f.of("deleteMessage")) != 0 {
t.Fatal("a new handle deleted a message")
}
subject, body = work(channel.Show, channel.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42",
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "fedcba9876543210fedcba9876543210"}}})
subject, body = work(occ.Show, oc.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42",
Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "fedcba9876543210fedcba9876543210"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
@@ -686,7 +687,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message can't be deleted"}`
}
second := st.placed["m1"].ID
subject, body = work(channel.Send, channel.Message{Handle: "m1", Title: "Now urgent: ace silent", Urgent: true, To: "42"})
subject, body = work(occ.Send, oc.Message{Handle: "m1", Title: "Now urgent: ace silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
ed := f.of("editMessageText")
if len(ed) != 1 || ed[0].Body["message_id"] != float64(second) || !strings.Contains(ed[0].Body["text"].(string), "Replaced") {
@@ -705,7 +706,7 @@ func TestAReplacementThatCannotBeSentLeavesTheEarlierMessage(t *testing.T) {
f.answer["sendMessage"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
}
subject, body := work(channel.Send, channel.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42"})
subject, body := work(occ.Send, oc.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
if len(f.of("deleteMessage")) != 0 || st.placed["m1"].ID != 7 {
t.Fatalf("the earlier message was taken away though its replacement was not sent: %+v", f.of("deleteMessage"))
+2 -2
View File
@@ -20,7 +20,7 @@ import (
"strings"
"unicode/utf8"
"git.novox.be/novox/mesh-sdk/go/channel"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// ParseMode is the formatting the Bot API is told the text is in.
@@ -40,7 +40,7 @@ type shown struct{ title, body, note string }
// first: the note carries what each answer does and every value an approval sets, so it is kept whole as long as
// the title and the note fit; only then is the note cut, and that said too. The bound is on the words, as
// Telegram's is on the text once its markup is read.
func partsOf(m channel.Message) shown {
func partsOf(m oc.Message) shown {
s := shown{title: strings.TrimSpace(m.Title), body: strings.TrimSpace(m.Body), note: strings.TrimSpace(m.Note)}
over := utf8.RuneCountInString(s.plain()) - MaxLength
if over <= 0 {
+8 -7
View File
@@ -4,13 +4,14 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/channel"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// adr0343 is the ask for ADR 0343 as the router now sends it to this channel (novox/hq issue 639): the knowledge
// base's brief as its title and body, the rest in its note. On 2026-10-13 the same ask arrived as about thirty plain
// lines — every one of its thirteen clauses on a line of its own — and the operator would not read it.
var adr0343 = channel.Message{Handle: "h-343", To: "42",
var adr0343 = oc.Message{Handle: "h-343", To: "42",
Title: "Your agents' memory follows you to every machine",
Body: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory files " +
@@ -22,13 +23,13 @@ var adr0343 = channel.Message{Handle: "h-343", To: "42",
"Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.\n" +
"ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
"Read its 13 clauses with the knowledge base's get, number 343.",
Buttons: []channel.Button{{Label: "Approve", Ticket: "t-approve"}, {Label: "Decline", Ticket: "t-decline"}}}
Buttons: []oc.Button{{Label: "Approve", Ticket: "t-approve"}, {Label: "Decline", Ticket: "t-decline"}}}
// The ADR 0343 ask fits a phone's glance: a bold headline, the summary and its points, then the rest apart and
// collapsed — at most twelve lines in all, and never a clause.
func TestTheADR0343AskFitsAGlance(t *testing.T) {
c, f, _, _ := newChannel(t, goodToken)
subject, body := work(channel.Show, adr0343)
subject, body := work(occ.Show, adr0343)
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
@@ -64,7 +65,7 @@ func TestTheADR0343AskFitsAGlance(t *testing.T) {
// The note is kept whole when the message is too long: the body is cut first and the cut said; only a note that
// cannot fit beside the title is cut, and that said too (the review of mesh-catalog #278).
func TestALongBodyIsCutBeforeTheNote(t *testing.T) {
m := channel.Message{Title: "t", Body: strings.Repeat("b", 5000), Note: "+ shares: media=/storage/media\nFingerprint 1234."}
m := oc.Message{Title: "t", Body: strings.Repeat("b", 5000), Note: "+ shares: media=/storage/media\nFingerprint 1234."}
s := partsOf(m)
if s.note != m.Note || !strings.HasSuffix(s.body, cutNote) || len([]rune(s.plain())) != MaxLength {
t.Errorf("note %q, body ends %q, %d characters", s.note, s.body[len(s.body)-20:], len([]rune(s.plain())))
@@ -78,7 +79,7 @@ func TestALongBodyIsCutBeforeTheNote(t *testing.T) {
// A '<', '>' or '&' in the note is escaped like any other part: it neither breaks the blockquote nor injects.
func TestTheNoteIsEscaped(t *testing.T) {
got := partsOf(channel.Message{Title: "t", Note: "Asked by <b>x</b> & y </blockquote><i>"}).html()
got := partsOf(oc.Message{Title: "t", Note: "Asked by <b>x</b> & y </blockquote><i>"}).html()
want := "<b>t</b>\n\n<blockquote expandable>Asked by &lt;b&gt;x&lt;/b&gt; &amp; y &lt;/blockquote&gt;&lt;i&gt;</blockquote>"
if got != want {
t.Errorf("%q", got)
@@ -89,7 +90,7 @@ func TestTheNoteIsEscaped(t *testing.T) {
// the end of the body (the asker's Details) is cut; the headline and the answer are never.
func TestAnAnsweredAskOverTheBoundKeepsItsHeadlineAndItsAnswer(t *testing.T) {
answer := "You chose Approve at 20:03 on Telegram.\nTold to mesh-knowledge, which acts on it itself."
m := channel.Message{Handle: "h-714", To: "42", Title: "Fixes reach every machine at once, unchecked",
m := oc.Message{Handle: "h-714", To: "42", Title: "Fixes reach every machine at once, unchecked",
Body: "A fix goes to every machine in one step.\n• No machine goes first\n" + strings.Repeat("Details line. ", 400),
Note: answer}
s := partsOf(m)
+5
View File
@@ -12,3 +12,8 @@ require (
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
require (
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
)