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
@@ -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
)