Skip to content

Commit 5bdf5a5

Browse files
authored
fix(telegram): preserve code block when editing approval messages (#21)
* fix(telegram): preserve code block when editing approval messages Edits on approval prompts (resolved by button, timed out, or cancelled) were appending a status suffix to a stored rendered HTML string. Under some conditions the appended suffix caused Telegram to drop the <pre><code class="language-json"> formatting on the args block, so approved/denied prompts lost the pretty-printed JSON code block. Switch msgMap to store the full ApprovalRequest instead of a pre-rendered text string. Every edit path (CancelApproval, inflight timeout, callback-resolved, callback-timed-out) now re-renders the body by calling FormatApprovalMessage(am.req) fresh and appending the status suffix to the result. This guarantees the <pre><code> block is always intact in the final body we send to editMessageText. * fix(telegram): pre-load msgMap to avoid edit race with cancelOnChannels broker.Resolve synchronously calls cancelOnChannels -> tc.CancelApproval on the same Telegram channel that just resolved the request. That cleanup path did LoadAndDelete on msgMap and issued its own "(resolved via another channel)" edit. Control then returned to handleCallback, which could no longer find the entry in msgMap and fell back to editing with cq.Message.Text - Telegram's plain-text extraction, where <pre>/<code> tags are already stripped. The second edit clobbered the first, leaving the user with plain-text JSON after tapping Allow/Deny. Fix: handleCallback now LoadAndDelete's the msgMap entry into a local 'am' variable before calling broker.Resolve. This takes ownership of the entry so CancelApproval becomes a no-op, and handleCallback's edit becomes the single last-write-wins render with the <pre><code> block intact and the "Allowed (once)/(request timed out)" label appended. Add regression test TestHandleCallbackAllowOncePreservesCodeBlockOnEdit that simulates an MCP approval with ToolArgs, taps Allow, and asserts the final editMessageText payload still contains <pre><code class="language-json"> and the status label.
1 parent aff2467 commit 5bdf5a5

2 files changed

Lines changed: 139 additions & 24 deletions

File tree

internal/telegram/approval.go

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,15 @@ func NewTelegramChannel(cfg ChannelConfig) (*TelegramChannel, error) {
101101
return tc, nil
102102
}
103103

104-
// approvalMsg stores the Telegram message ID and original text for an
105-
// approval request so that timeout/cancel edits can preserve context.
104+
// approvalMsg stores the Telegram message ID and the original approval
105+
// request so that timeout/cancel/resolved edits can re-render the full
106+
// HTML-formatted body (including the <pre><code> args block) without
107+
// worrying about whether the stored text survived appending, parse
108+
// mode changes, or Telegram's plain-text extraction in callback
109+
// queries.
106110
type approvalMsg struct {
107111
messageID int
108-
text string
112+
req channel.ApprovalRequest
109113
}
110114

111115
// SetBroker sets the broker reference for resolving approval requests.
@@ -155,15 +159,14 @@ func (tc *TelegramChannel) sendApprovalMessage(req channel.ApprovalRequest) {
155159
}
156160
return
157161
}
158-
originalText := FormatApprovalMessage(req)
159-
tc.msgMap.Store(req.ID, approvalMsg{messageID: sent.MessageID, text: originalText})
162+
tc.msgMap.Store(req.ID, approvalMsg{messageID: sent.MessageID, req: req})
160163

161164
// If the request timed out while the Telegram API call was in
162165
// flight, update the message immediately so the user does not
163166
// see a stale prompt.
164167
if tc.broker != nil && tc.broker.WasTimedOut(req.ID) {
165168
edit := tgbotapi.NewEditMessageText(tc.chatID, sent.MessageID,
166-
originalText+"\n\n(request timed out)")
169+
FormatApprovalMessage(req)+"\n\n(request timed out)")
167170
edit.ParseMode = tgbotapi.ModeHTML
168171
if _, editErr := tc.api.Send(edit); editErr == nil {
169172
tc.broker.ClearTimedOut(req.ID)
@@ -186,7 +189,8 @@ func (tc *TelegramChannel) CancelApproval(id string) error {
186189
} else if tc.broker != nil && tc.broker.IsClosed() {
187190
reason = "(proxy shutting down)"
188191
}
189-
edit := tgbotapi.NewEditMessageText(tc.chatID, am.messageID, am.text+"\n\n"+reason)
192+
edit := tgbotapi.NewEditMessageText(tc.chatID, am.messageID,
193+
FormatApprovalMessage(am.req)+"\n\n"+reason)
190194
edit.ParseMode = tgbotapi.ModeHTML
191195
_, _ = tc.api.Send(edit)
192196
return nil
@@ -300,6 +304,22 @@ func (tc *TelegramChannel) handleCallback(cq *tgbotapi.CallbackQuery) {
300304
return
301305
}
302306

307+
// Take ownership of the msgMap entry BEFORE calling broker.Resolve.
308+
// Resolve synchronously calls cancelOnChannels, which invokes
309+
// tc.CancelApproval on this same Telegram channel. CancelApproval
310+
// does its own LoadAndDelete and issues a "(resolved via another
311+
// channel)" edit. If we let that happen first, the msgMap entry is
312+
// gone by the time handleCallback runs its own edit, and we fall
313+
// back to cq.Message.Text (Telegram's plain-text extraction where
314+
// <pre><code> tags are already stripped). Pre-loading makes
315+
// CancelApproval a no-op so handleCallback owns the final render.
316+
var am approvalMsg
317+
haveAM := false
318+
if val, ok := tc.msgMap.LoadAndDelete(reqID); ok {
319+
am = val.(approvalMsg)
320+
haveAM = true
321+
}
322+
303323
resolved := false
304324
if tc.broker != nil {
305325
resolved = tc.broker.Resolve(reqID, resp)
@@ -309,32 +329,37 @@ func (tc *TelegramChannel) handleCallback(cq *tgbotapi.CallbackQuery) {
309329
callback := tgbotapi.NewCallback(cq.ID, label)
310330
_, _ = tc.api.Request(callback)
311331

312-
// Use the original stored text (with HTML markup) rather than
313-
// cq.Message.Text, which is Telegram's plain-text extraction
314-
// and loses our <pre><code> formatting on tool arguments.
315-
originalText := cq.Message.Text
316-
if val, ok := tc.msgMap.Load(reqID); ok {
317-
originalText = val.(approvalMsg).text
332+
// Re-render the full HTML body from the stored request. This
333+
// preserves the <pre><code> args block because we are not
334+
// appending to or reusing a string that already contains HTML
335+
// tags. cq.Message.Text is Telegram's plain-text extraction and
336+
// loses the code block formatting entirely, so we fall back to
337+
// a minimal plain message only if the msgMap entry is gone.
338+
var body string
339+
if haveAM {
340+
body = fmt.Sprintf("%s\n\n%s at %s",
341+
FormatApprovalMessage(am.req), label, time.Now().UTC().Format("15:04:05"))
342+
} else {
343+
body = fmt.Sprintf("%s\n\n%s at %s",
344+
cq.Message.Text, label, time.Now().UTC().Format("15:04:05"))
318345
}
319-
edit := tgbotapi.NewEditMessageText(tc.chatID, cq.Message.MessageID,
320-
fmt.Sprintf("%s\n\n%s at %s", originalText, label, time.Now().UTC().Format("15:04:05")))
346+
edit := tgbotapi.NewEditMessageText(tc.chatID, cq.Message.MessageID, body)
321347
edit.ParseMode = tgbotapi.ModeHTML
322348
_, _ = tc.api.Send(edit)
323-
tc.msgMap.Delete(reqID)
324349
} else if tc.broker != nil && tc.broker.WasTimedOut(reqID) {
325350
tc.broker.ClearTimedOut(reqID)
326351
callback := tgbotapi.NewCallback(cq.ID, "Request timed out")
327352
_, _ = tc.api.Request(callback)
328353

329-
originalText := cq.Message.Text
330-
if val, ok := tc.msgMap.Load(reqID); ok {
331-
originalText = val.(approvalMsg).text
354+
var body string
355+
if haveAM {
356+
body = FormatApprovalMessage(am.req) + "\n\n(request timed out)"
357+
} else {
358+
body = cq.Message.Text + "\n\n(request timed out)"
332359
}
333-
edit := tgbotapi.NewEditMessageText(tc.chatID, cq.Message.MessageID,
334-
originalText+"\n\n(request timed out)")
360+
edit := tgbotapi.NewEditMessageText(tc.chatID, cq.Message.MessageID, body)
335361
edit.ParseMode = tgbotapi.ModeHTML
336362
_, _ = tc.api.Send(edit)
337-
tc.msgMap.Delete(reqID)
338363

339364
} else {
340365
// Request was already resolved by a previous callback (e.g. double-tap

internal/telegram/approval_test.go

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,10 @@ func TestCancelApproval(t *testing.T) {
459459
tc.SetBroker(broker)
460460

461461
// Store a message ID mapping.
462-
tc.msgMap.Store("req_test", approvalMsg{messageID: 42, text: "test approval message"})
462+
tc.msgMap.Store("req_test", approvalMsg{
463+
messageID: 42,
464+
req: channel.ApprovalRequest{ID: "req_test", Destination: "test.example.com", Port: 443, Protocol: "https"},
465+
})
463466

464467
err := tc.CancelApproval("req_test")
465468
if err != nil {
@@ -508,7 +511,10 @@ func TestCancelApprovalShowsShutdownReason(t *testing.T) {
508511
tc.SetBroker(broker)
509512
broker.CancelAll() // Mark broker as closed.
510513

511-
tc.msgMap.Store("req_shutdown", approvalMsg{messageID: 43, text: "shutdown test message"})
514+
tc.msgMap.Store("req_shutdown", approvalMsg{
515+
messageID: 43,
516+
req: channel.ApprovalRequest{ID: "req_shutdown", Destination: "shutdown.example.com", Port: 443, Protocol: "https"},
517+
})
512518
_ = tc.CancelApproval("req_shutdown")
513519

514520
time.Sleep(50 * time.Millisecond)
@@ -808,6 +814,90 @@ func TestHandleCallbackUnknownAction(t *testing.T) {
808814
})
809815
}
810816

817+
// TestHandleCallbackAllowOncePreservesCodeBlockOnEdit reproduces the bug
818+
// where the <pre><code> args block was lost from the final edit after the
819+
// user tapped Allow. The root cause was that broker.Resolve synchronously
820+
// calls cancelOnChannels -> tc.CancelApproval on the same Telegram channel,
821+
// which LoadAndDelete'd the msgMap entry and issued its own edit. Control
822+
// then returned to handleCallback, which could not find the entry in
823+
// msgMap and fell back to editing with cq.Message.Text (Telegram's
824+
// plain-text extraction, where <pre>/<code> tags are already stripped).
825+
// That second edit clobbered the first and lost the code block. The fix
826+
// is for handleCallback to take ownership of the msgMap entry BEFORE
827+
// calling broker.Resolve so CancelApproval becomes a no-op.
828+
func TestHandleCallbackAllowOncePreservesCodeBlockOnEdit(t *testing.T) {
829+
mock := newMockTelegramAPI(t)
830+
s := newTestStore(t)
831+
tc := newTestTelegramChannel(t, mock, s)
832+
833+
broker := channel.NewBroker([]channel.Channel{tc})
834+
tc.SetBroker(broker)
835+
836+
const toolArgs = `{"owner":"me","repo":"x"}`
837+
done := make(chan channel.Response, 1)
838+
go func() {
839+
resp, _ := broker.Request(
840+
"github__list_branches", 0, "mcp", 5*time.Second,
841+
channel.WithToolArgs(toolArgs),
842+
)
843+
done <- resp
844+
}()
845+
846+
waitForPending(t, broker, 1)
847+
reqs := broker.PendingRequests()
848+
if len(reqs) != 1 {
849+
t.Fatalf("expected 1 pending request, got %d", len(reqs))
850+
}
851+
req := reqs[0]
852+
853+
// Wait for sendApprovalMessage goroutine to populate msgMap (it runs
854+
// async after broadcast). The mock API returns immediately so this is
855+
// typically fast.
856+
deadline := time.Now().Add(500 * time.Millisecond)
857+
for time.Now().Before(deadline) {
858+
if _, ok := tc.msgMap.Load(req.ID); ok {
859+
break
860+
}
861+
time.Sleep(5 * time.Millisecond)
862+
}
863+
if _, ok := tc.msgMap.Load(req.ID); !ok {
864+
t.Fatal("msgMap entry was not populated by sendApprovalMessage")
865+
}
866+
867+
// Simulate user tapping "Allow". Message.Text is what Telegram returns
868+
// in callback queries: the sent HTML text with all formatting tags
869+
// stripped. The JSON content is still present (newlines preserved)
870+
// but the <pre><code> wrapping is gone.
871+
tc.handleCallback(&tgbotapi.CallbackQuery{
872+
ID: "cb_allow",
873+
Message: &tgbotapi.Message{
874+
MessageID: 101,
875+
Chat: &tgbotapi.Chat{ID: 12345},
876+
Text: "OpenClaw wants to call tool:\n\ngithub__list_branches\n\nArguments:\n{\n \"owner\": \"me\",\n \"repo\": \"x\"\n}\n\nAllow this tool call?",
877+
},
878+
Data: req.ID + "|allow_once",
879+
})
880+
881+
if resp := <-done; resp != channel.ResponseAllowOnce {
882+
t.Errorf("expected AllowOnce, got %v", resp)
883+
}
884+
885+
// The FINAL edit (last-write-wins) must still contain the code block
886+
// and the "Allowed (once)" status label. Multiple edits may fire
887+
// (cancelOnChannels also triggers one), so we check the last entry.
888+
edits := mock.getEditedMessages()
889+
if len(edits) == 0 {
890+
t.Fatal("expected at least one edit")
891+
}
892+
last := edits[len(edits)-1]
893+
if !strings.Contains(last.Text, `<pre><code class="language-json">`) {
894+
t.Errorf("final edit lost <pre><code> block.\nfinal edit text:\n%s", last.Text)
895+
}
896+
if !strings.Contains(last.Text, "Allowed (once)") {
897+
t.Errorf("final edit missing 'Allowed (once)' label.\nfinal edit text:\n%s", last.Text)
898+
}
899+
}
900+
811901
func TestHandleCallbackAlreadyResolved(t *testing.T) {
812902
mock := newMockTelegramAPI(t)
813903
s := newTestStore(t)

0 commit comments

Comments
 (0)