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
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <shanks> & 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 <shanks> & 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 <b>tag</b> & 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.
|
||||
|
||||
@@ -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("&", "&", "<", "<", ">", ">").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")
|
||||
}
|
||||
Reference in New Issue
Block a user