A proposal reads whole on the phone, and Telegram shows every message formatted (hq issue 383)
mesh/delivery-group group fix/383-a-proposal-reads-whole-on-the-phone checking: 0 of 2 member(s) ready
mesh/merge-gate pass: builds claude-code, messenger, telegram → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did with…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request

The operator could not read a settings proposal on the phone: its values were masked ("+ shares:
media=‹path›"), a how-to paragraph followed the fingerprint, and nothing was formatted.

messenger: an ask may carry its explanation whole (asks.Ask.Whole); the router shows it in
place of the explanation only on a kind the controller issued as verified-sender and that was
given the ask's answers, and holds it to the secret rule alone (CheckSecret): a path or an
address is what the operator approves, a secret's shape leaves the mesh nowhere and refuses the
ask as a whole. Everywhere else, and for every condition, the explanation under the content rule
stands. An ask's own message offers Details when the ask says what Details shows
(asks.Ask.Details): the fingerprint and where to read the proposal whole. The expiry line reads
"Expires <when>: <what then>".

telegram: every message is sent in HTML, formatted and never reworded: the title bold, a key
line or a fingerprint in monospace, the chosen line bold, and every '<', '>' and '&' escaped
first. What a tap is compared by stays the plain words, which is what Telegram gives back.

claude-code: an instruction proposal's ask has the same shape — what is asked, the text, who
proposed it and when; the fingerprint and the how-to are the Details answer's; where the sender
is proven, a line with a path or an address is shown, one shaped like a secret is not.

Takes mesh-sdk go at the commit that adds Whole and Details to an ask.
This commit is contained in:
jochen
2026-10-10 15:38:00 +02:00
parent 64feabc6c3
commit 96ba664cb4
20 changed files with 512 additions and 91 deletions
+6 -4
View File
@@ -249,9 +249,10 @@ func keyboard(buttons [][2]string) map[string]any {
return map[string]any{"inline_keyboard": rows}
}
// Send posts a message and answers its id. silent: it arrives without sound (D4).
// Send posts a message, its text in ParseMode (rendered or escaped by the caller), and answers its id. silent:
// it arrives without sound (D4).
func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (int64, error) {
body := map[string]any{"chat_id": chat, "text": text, "disable_notification": silent,
body := map[string]any{"chat_id": chat, "text": text, "parse_mode": ParseMode, "disable_notification": silent,
"link_preview_options": map[string]any{"is_disabled": true}}
if len(buttons) > 0 {
body["reply_markup"] = keyboard(buttons)
@@ -261,9 +262,10 @@ func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (i
return out.MessageID, err
}
// Edit replaces a message's text and buttons; none given, the buttons go.
// Edit replaces a message's text (in ParseMode, rendered or escaped by the caller) and buttons; none given, the
// buttons go.
func (b *Bot) Edit(chat, id int64, text string, buttons [][2]string) error {
err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text,
err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text, "parse_mode": ParseMode,
"reply_markup": keyboard(buttons), "link_preview_options": map[string]any{"is_disabled": true}}, nil)
if notModified(err) {
return nil
+11 -6
View File
@@ -7,7 +7,9 @@ package main
//
// - **Work** comes from its kind's worker on the `channel` queue: `show` (a message with buttons, each
// carrying only the router's ticket), `send` (words), `edit` (a message shown before, by the router's
// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). A warning or a
// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). The words are shown
// as the router gave them, formatted (render.go): the title bold, a key line or a fingerprint in
// monospace, escaped so that none of them is read as markup (issue 383). A warning or a
// clearing arrives without sound, an urgent message or a question rings (D4). A rate limit is waited out
// as told (D5); a refusal is a permanent failure, said to the router, never retried; an edit to the same
// text is a success (D6). A message that cannot be delivered after three tries is said as failed. A
@@ -155,7 +157,9 @@ func (c *Channel) init() {
}
}
// text is a message as the chat shows it, cut at MaxLength and the cut said (D1).
// text is a message's plain words, cut at MaxLength and the cut said (D1): what the digest of a shown message
// is over, and what render makes the chat's text from. The bound is on the words, as Telegram's is on the text
// once its markup is read.
func text(m asks.Message) string {
t := m.Title
if m.Body != "" {
@@ -253,7 +257,7 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
var id int64
switch {
case place != nil:
err = c.Bot.Edit(place.Chat, place.ID, words, buttons)
err = c.Bot.Edit(place.Chat, place.ID, render(words), buttons)
if err == nil || notModified(err) {
c.keepShown(place.Chat, place.ID, words)
}
@@ -297,13 +301,14 @@ func (c *Channel) replaced(p Placed) {
return
}
c.Logf("[telegram] an earlier message could not be deleted (%v); its buttons are taken away instead", err)
if err := c.Bot.Edit(p.Chat, p.ID, "Replaced by a newer message below.", nil); err != nil && !gone(err) {
if err := c.Bot.Edit(p.Chat, p.ID, escape("Replaced by a newer message below."), nil); err != nil && !gone(err) {
c.Logf("[telegram] an earlier message could not be marked replaced either: %v", err)
}
}
// newMessage sends words, rendered (render.go), and keeps where they are and what they say, plain.
func (c *Channel) newMessage(handle string, chat int64, words string, buttons [][2]string, silent bool, id *int64) error {
got, err := c.Bot.Send(chat, words, buttons, silent)
got, err := c.Bot.Send(chat, render(words), buttons, silent)
if err != nil {
return err
}
@@ -461,7 +466,7 @@ func (c *Channel) remember(id string, chat int64) {
}
func (c *Channel) reply(chat int64, words string) {
if _, err := c.Bot.Send(chat, words, nil, true); err != nil {
if _, err := c.Bot.Send(chat, escape(words), nil, true); err != nil {
c.Logf("[telegram] could not answer in the chat: %v", err)
}
}
+52 -3
View File
@@ -202,7 +202,7 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T)
}
b := sent[0].Body
kb, _ := json.Marshal(b["reply_markup"])
if b["chat_id"] != float64(42) || b["text"] != "Delivery of hq held\nNeeds you: release it." || b["disable_notification"] != false ||
if b["chat_id"] != float64(42) || b["text"] != "<b>Delivery of hq held</b>\nNeeds you: release it." || b["disable_notification"] != false ||
string(kb) != `{"inline_keyboard":[[{"callback_data":"0123456789abcdef0123456789abcdef","text":"Release"}]]}` {
t.Errorf("sent %v %s", b, kb)
}
@@ -230,12 +230,61 @@ func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
got := f.of("sendMessage")[0].Body["text"].(string)
// The bound is on the words, as Telegram's is on the text once its markup is read: the tags do not count.
got := strings.NewReplacer("<b>", "", "</b>", "").Replace(f.of("sendMessage")[0].Body["text"].(string))
if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) {
t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:])
}
}
// A message is shown formatted, never reworded (issue 383): the title bold, a key line and a fingerprint in
// monospace, the chosen line bold, and every '<', '>' and '&' escaped, so a message carrying one neither breaks
// 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(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on <shanks> & co?",
Body: "+ shares: media=/storage/media\n~ sources: a=b (was: c)\nProposed by an agent at 15:11.\nFingerprint 1234 5678 9abc.",
To: "42", Buttons: []asks.Button{{Label: "Approve", Ticket: "tkt"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
sent := f.of("sendMessage")
want := "<b>Set mounts on &lt;shanks&gt; &amp; co?</b>\n<code>+ shares: media=/storage/media</code>\n<code>~ sources: a=b (was: c)</code>\n" +
"Proposed by an agent at 15:11.\nFingerprint <code>1234 5678 9abc.</code>"
if len(sent) != 1 || sent[0].Body["text"] != want || sent[0].Body["parse_mode"] != "HTML" {
t.Fatalf("sent %v", sent)
}
f.mu.Lock()
shown := f.next
f.mu.Unlock()
// Telegram gives the words back plain: the tap on them is taken.
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: "Set mounts on <shanks> & co?\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\nProposed by an agent at 15:11.\nFingerprint 1234 5678 9abc."}}})
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
t.Fatalf("the tap on the rendered message was not taken: %s", got)
}
// The edit says what was chosen, in bold, and is in the same mode.
subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?",
Body: "Told to mesh-controller, which acts on it itself.\n+ shares: media=/storage/media", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
ed := f.of("editMessageText")
if len(ed) != 1 || ed[0].Body["parse_mode"] != "HTML" ||
ed[0].Body["text"] != "<b>You chose Approve at 15:06 on Telegram · Set mounts on &lt;shanks&gt; &amp; co?</b>\nTold to mesh-controller, which acts on it itself.\n<code>+ shares: media=/storage/media</code>" {
t.Errorf("edited %v", ed)
}
if got := render("Delivery held\nYou chose Release at 13:30 on Telegram.\nTold to mesh-delivery."); got != "<b>Delivery held</b>\n<b>You chose Release at 13:30 on Telegram.</b>\nTold to mesh-delivery." {
t.Errorf("the chosen line on a condition's message: %q", got)
}
// A plain answer in the chat is escaped too.
c.reply(42, "a <b>tag</b> & more")
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != "a &lt;b&gt;tag&lt;/b&gt; &amp; more" {
t.Errorf("a reply was not escaped: %v", last[len(last)-1].Body["text"])
}
}
func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) {
c, f, s, st := newChannel(t, goodToken)
st.Place("m1", Placed{Chat: 42, ID: 7})
@@ -528,7 +577,7 @@ func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "You chose Release: recorded." {
if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "<b>You chose Release: recorded.</b>" {
t.Errorf("the late word was not said in the chat: %v", sent)
}
// Telegram refuses an acknowledgement that came too late: the word goes to the chat instead.
+56
View File
@@ -0,0 +1,56 @@
package main
// How a message looks in the chat (novox/hq issue 383, the operator's word of 2026-10-10: "no bold text, no
// formatting"). The router's words are shown as given — `exact-render` — and formatted, never reworded: the
// title in bold, a line that is a key with its value (as a settings proposal lists them, "+ key: value") or a
// fingerprint in monospace, and the line that says what was chosen in bold. Every character Telegram's HTML
// would read as markup is escaped first, so a message carrying '<' neither breaks nor injects.
//
// What the operator taps on is compared by its plain words (wordsDigest): Telegram gives a message's text back
// without its formatting, so the digest is over the words before they are rendered.
import (
"regexp"
"strings"
)
// ParseMode is the formatting the Bot API is told the text is in.
const ParseMode = "HTML"
var (
keyLine = regexp.MustCompile(`^[+~=-] `)
fingerprintLine = regexp.MustCompile(`^(Fingerprint )(.+)$`)
chosenLine = regexp.MustCompile(`^(You|Somebody) chose `)
)
// escape is a text with the characters Telegram's HTML reads as markup written as entities.
func escape(s string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(s)
}
// render is a message's plain words (text) as the chat shows them: the first line, the title, in bold; each
// line after it as it is, a key line or a fingerprint in monospace, the chosen line in bold.
func render(words string) string {
lines := strings.Split(words, "\n")
out := make([]string, 0, len(lines))
for i, line := range lines {
switch {
case i == 0:
if strings.TrimSpace(line) == "" {
out = append(out, "")
continue
}
out = append(out, "<b>"+escape(line)+"</b>")
case keyLine.MatchString(line):
out = append(out, "<code>"+escape(line)+"</code>")
case fingerprintLine.MatchString(line):
m := fingerprintLine.FindStringSubmatch(line)
out = append(out, escape(m[1])+"<code>"+escape(m[2])+"</code>")
case chosenLine.MatchString(line):
out = append(out, "<b>"+escape(line)+"</b>")
default:
out = append(out, escape(line))
}
}
return strings.Join(out, "\n")
}