Skip to content

Commit 6f673c4

Browse files
authored
fix(telegram): stop auto-linking destinations in /policy show (#32)
* fix(telegram): stop auto-linking destinations in /policy show Telegram was rendering every destination string in /policy show output as a clickable blue link (example.com, api.github.com, etc.) because the message was sent in plain text mode and Telegram auto-detects URL patterns. Wrap the output in <pre>...</pre> and send with ParseMode=HTML + DisableWebPagePreview. Inside a preformatted block Telegram leaves URL patterns alone, and the monospace rendering also aligns the columns better. - commands.go: htmlPreOpen/htmlPreClose constants, htmlEscapeText helper, wrap both policyShow (engine fallback) and policyShowFromStore in <pre>. Escape destination, tool, pattern, replacement, name, source, default verdict, and protocols. - approval.go: sendReply path sniffs the <pre> prefix and switches to HTML parse mode with web-page preview disabled. Other replies are still plain text. - Tests: TestPolicyShowEscapesHTML and a wrapping check in TestPolicyShowIncludesAllFields. * fix(telegram): wrap destinations in <code> for approvals and mutations Extends the /policy show fix to the other Telegram surfaces that render destinations or URLs: approval prompts and policy mutation confirmations (allow/deny). Without this, Telegram auto-links the destination in strings like 'HTTPS api.github.com:443' or 'Added allow rule: example.com' as a clickable blue URL. - bot.go FormatApprovalMessage: wrap dest:port and the rendered request URL in <code>. MCP tool name also in <code>. - commands.go policyAllow / policyDeny: wrap dest in <code> on both store and in-memory paths. - Consolidate the duplicated htmlEscape helpers: commands.go now uses the existing htmlEscape from bot.go, local wrapper removed. - Update bot_test.go expectations to match the <code>-wrapped output.
1 parent b519e2c commit 6f673c4

5 files changed

Lines changed: 85 additions & 34 deletions

File tree

internal/telegram/approval.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,15 @@ func (tc *TelegramChannel) handleMessage(msg *tgbotapi.Message) {
434434
}
435435

436436
reply := tgbotapi.NewMessage(tc.chatID, response)
437+
// Responses that contain <code> or <b> are pre-rendered HTML (see
438+
// policyShow / policyShowFromStore). Send them with HTML parse mode so
439+
// the tags render and Telegram does not auto-link URLs inside <code>.
440+
// Plain-text responses never contain these tags because dynamic input
441+
// gets HTML-escaped before insertion.
442+
if strings.Contains(response, "<code>") || strings.Contains(response, "<b>") {
443+
reply.ParseMode = tgbotapi.ModeHTML
444+
reply.DisableWebPagePreview = true
445+
}
437446
if _, err := tc.api.Send(reply); err != nil {
438447
log.Printf("telegram send error: %s", sanitizeError(err))
439448
}

internal/telegram/bot.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ var protoDisplayName = map[string]string{
5757
// Callers must set ParseMode to HTML when sending the message.
5858
func FormatApprovalMessage(req channel.ApprovalRequest) string {
5959
if req.Protocol == "mcp" {
60-
msg := "OpenClaw wants to call tool:\n\n" + htmlEscape(req.Destination)
60+
msg := "OpenClaw wants to call tool:\n\n" + htmlCode(req.Destination)
6161
if req.ToolArgs != "" {
6262
pretty := prettyJSONOrRaw(req.ToolArgs)
6363
msg += "\n\nArguments:\n<pre><code class=\"language-json\">" + htmlEscape(pretty) + "</code></pre>"
@@ -69,20 +69,21 @@ func FormatApprovalMessage(req channel.ApprovalRequest) string {
6969
if display == "" {
7070
display = req.Protocol
7171
}
72+
destPort := fmt.Sprintf("%s:%d", req.Destination, req.Port)
7273
if req.Method != "" {
7374
ver := ""
7475
if req.HTTPVersion != "" {
7576
ver = " (" + htmlEscape(req.HTTPVersion) + ")"
7677
}
7778
return fmt.Sprintf(
78-
"OpenClaw wants to connect to:\n\n%s %s:%d\n%s %s%s\n\nAllow this request?",
79-
htmlEscape(display), htmlEscape(req.Destination), req.Port,
80-
htmlEscape(req.Method), htmlEscape(buildRequestURL(req)), ver,
79+
"OpenClaw wants to connect to:\n\n%s %s\n%s %s%s\n\nAllow this request?",
80+
htmlEscape(display), htmlCode(destPort),
81+
htmlEscape(req.Method), htmlCode(buildRequestURL(req)), ver,
8182
)
8283
}
8384
return fmt.Sprintf(
84-
"OpenClaw wants to connect to:\n\n%s %s:%d\n\nAllow this connection?",
85-
htmlEscape(display), htmlEscape(req.Destination), req.Port,
85+
"OpenClaw wants to connect to:\n\n%s %s\n\nAllow this connection?",
86+
htmlEscape(display), htmlCode(destPort),
8687
)
8788
}
8889

internal/telegram/bot_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ func TestFormatApprovalMessage(t *testing.T) {
7777
Protocol: "http",
7878
}
7979
msg := FormatApprovalMessage(req)
80-
if !strings.Contains(msg, "HTTP example.com:8080") {
81-
t.Errorf("expected 'HTTP example.com:8080' in message, got: %s", msg)
80+
if !strings.Contains(msg, "HTTP <code>example.com:8080</code>") {
81+
t.Errorf("expected 'HTTP <code>example.com:8080</code>' in message, got: %s", msg)
8282
}
8383
})
8484

@@ -167,10 +167,10 @@ func TestFormatApprovalMessage(t *testing.T) {
167167
Path: "/users/me",
168168
}
169169
msg := FormatApprovalMessage(req)
170-
if !strings.Contains(msg, "HTTPS api.example.com:443") {
170+
if !strings.Contains(msg, "HTTPS <code>api.example.com:443</code>") {
171171
t.Errorf("expected destination line in message, got: %s", msg)
172172
}
173-
if !strings.Contains(msg, "GET https://api.example.com/users/me") {
173+
if !strings.Contains(msg, "GET <code>https://api.example.com/users/me</code>") {
174174
t.Errorf("expected request line in message, got: %s", msg)
175175
}
176176
if !strings.Contains(msg, "Allow this request?") {
@@ -187,7 +187,7 @@ func TestFormatApprovalMessage(t *testing.T) {
187187
Path: "/api/submit",
188188
}
189189
msg := FormatApprovalMessage(req)
190-
if !strings.Contains(msg, "POST http://localhost:8080/api/submit") {
190+
if !strings.Contains(msg, "POST <code>http://localhost:8080/api/submit</code>") {
191191
t.Errorf("expected URL with explicit port, got: %s", msg)
192192
}
193193
})
@@ -201,7 +201,7 @@ func TestFormatApprovalMessage(t *testing.T) {
201201
Path: "",
202202
}
203203
msg := FormatApprovalMessage(req)
204-
if !strings.Contains(msg, "HEAD https://example.com/") {
204+
if !strings.Contains(msg, "HEAD <code>https://example.com/</code>") {
205205
t.Errorf("expected URL with default path '/', got: %s", msg)
206206
}
207207
})

internal/telegram/commands.go

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,7 @@ func (h *CommandHandler) policyShow() string {
216216
// Fallback to engine snapshot when store is not configured.
217217
snap := h.engine.Load().Snapshot()
218218
var b strings.Builder
219-
b.WriteString("Current policy (default: ")
220-
b.WriteString(snap.Default.String())
221-
b.WriteString(")\n\n")
219+
fmt.Fprintf(&b, "Current policy (default: %s)\n\n", htmlCode(snap.Default.String()))
222220

223221
for _, section := range []struct {
224222
label string
@@ -235,14 +233,14 @@ func (h *CommandHandler) policyShow() string {
235233
b.WriteString(":\n")
236234
for _, r := range section.rules {
237235
b.WriteString(" ")
238-
b.WriteString(r.Destination)
236+
b.WriteString(htmlCode(r.Destination))
239237
if len(r.Ports) > 0 {
240238
b.WriteString(" ports=")
241239
b.WriteString(formatPorts(r.Ports))
242240
}
243241
if len(r.Protocols) > 0 {
244242
b.WriteString(" protocols=")
245-
b.WriteString(strings.Join(r.Protocols, ","))
243+
b.WriteString(htmlCode(strings.Join(r.Protocols, ",")))
246244
}
247245
b.WriteString("\n")
248246
}
@@ -255,6 +253,13 @@ func (h *CommandHandler) policyShow() string {
255253
return b.String()
256254
}
257255

256+
// htmlCode wraps s in <code>...</code> after HTML-escaping it. Inside <code>
257+
// Telegram will not auto-detect URLs, so destinations like api.github.com
258+
// render as monospace text rather than clickable blue links.
259+
func htmlCode(s string) string {
260+
return "<code>" + htmlEscape(s) + "</code>"
261+
}
262+
258263
func (h *CommandHandler) policyShowFromStore() string {
259264
cfg, err := h.store.GetConfig()
260265
if err != nil {
@@ -271,9 +276,7 @@ func (h *CommandHandler) policyShowFromStore() string {
271276
}
272277

273278
var b strings.Builder
274-
b.WriteString("Current policy (default: ")
275-
b.WriteString(dv)
276-
b.WriteString(")\n\n")
279+
fmt.Fprintf(&b, "Current policy (default: %s)\n\n", htmlCode(dv))
277280

278281
for _, section := range []struct {
279282
label string
@@ -293,32 +296,33 @@ func (h *CommandHandler) policyShowFromStore() string {
293296
if len(sectionRules) == 0 {
294297
continue
295298
}
299+
b.WriteString("<b>")
296300
b.WriteString(section.label)
297-
b.WriteString(":\n")
301+
b.WriteString("</b>:\n")
298302
for _, r := range sectionRules {
299303
target := r.Destination
300304
if r.Tool != "" {
301305
target = "tool:" + r.Tool
302306
} else if r.Pattern != "" {
303307
target = "pattern:" + r.Pattern
304308
}
305-
fmt.Fprintf(&b, " [%d] %s", r.ID, target)
309+
fmt.Fprintf(&b, " [%d] %s", r.ID, htmlCode(target))
306310
if len(r.Ports) > 0 {
307311
b.WriteString(" ports=")
308312
b.WriteString(formatPorts(r.Ports))
309313
}
310314
if len(r.Protocols) > 0 {
311315
b.WriteString(" protocols=")
312-
b.WriteString(strings.Join(r.Protocols, ","))
316+
b.WriteString(htmlCode(strings.Join(r.Protocols, ",")))
313317
}
314318
if r.Replacement != "" {
315-
fmt.Fprintf(&b, " -> %q", r.Replacement)
319+
fmt.Fprintf(&b, " -> %s", htmlCode(r.Replacement))
316320
}
317321
if r.Name != "" {
318-
fmt.Fprintf(&b, " (%s)", r.Name)
322+
fmt.Fprintf(&b, " (%s)", htmlEscape(r.Name))
319323
}
320324
if r.Source != "" {
321-
fmt.Fprintf(&b, " [%s]", r.Source)
325+
fmt.Fprintf(&b, " [%s]", htmlEscape(r.Source))
322326
}
323327
b.WriteString("\n")
324328
}
@@ -350,14 +354,14 @@ func (h *CommandHandler) policyAllow(dest string) string {
350354
if err := h.recompileAndSwap(); err != nil {
351355
return fmt.Sprintf("Added allow rule but failed to recompile: %v", err)
352356
}
353-
return fmt.Sprintf("Added allow rule: %s", dest)
357+
return "Added allow rule: " + htmlCode(dest)
354358
}
355359

356360
// Fallback to in-memory mutation when store is not configured.
357361
if err := h.engine.Load().AddAllowRule(dest); err != nil { //nolint:staticcheck // backward compat fallback when no store
358362
return fmt.Sprintf("Failed to add allow rule: %v", err)
359363
}
360-
return fmt.Sprintf("Added allow rule: %s%s", dest, inMemoryWarning)
364+
return "Added allow rule: " + htmlCode(dest) + inMemoryWarning
361365
}
362366

363367
func (h *CommandHandler) policyDeny(dest string) string {
@@ -375,13 +379,13 @@ func (h *CommandHandler) policyDeny(dest string) string {
375379
if err := h.recompileAndSwap(); err != nil {
376380
return fmt.Sprintf("Added deny rule but failed to recompile: %v", err)
377381
}
378-
return fmt.Sprintf("Added deny rule: %s", dest)
382+
return "Added deny rule: " + htmlCode(dest)
379383
}
380384

381385
if err := h.engine.Load().AddDenyRule(dest); err != nil { //nolint:staticcheck // backward compat fallback when no store
382386
return fmt.Sprintf("Failed to add deny rule: %v", err)
383387
}
384-
return fmt.Sprintf("Added deny rule: %s%s", dest, inMemoryWarning)
388+
return "Added deny rule: " + htmlCode(dest) + inMemoryWarning
385389
}
386390

387391
func (h *CommandHandler) policyRemove(idStr string) string {

internal/telegram/commands_test.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -469,20 +469,57 @@ func TestPolicyShowIncludesAllFields(t *testing.T) {
469469
out := handler.Handle(&Command{Name: "policy", Args: []string{"show"}})
470470

471471
mustContain := []string{
472-
"example.com",
472+
"<code>example.com</code>",
473473
"ports=443",
474-
"protocols=quic",
474+
"protocols=<code>quic</code>",
475475
"(test rule)",
476476
"[manual]",
477-
"pattern:sk-[A-Za-z0-9]+",
478-
`-> "sk-REDACTED"`,
477+
"<code>pattern:sk-[A-Za-z0-9]+</code>",
478+
"-> <code>sk-REDACTED</code>",
479479
"[seed]",
480480
}
481481
for _, want := range mustContain {
482482
if !strings.Contains(out, want) {
483483
t.Errorf("policy show output missing %q\nfull output:\n%s", want, out)
484484
}
485485
}
486+
487+
// Section headers are bolded so the sender picks HTML parse mode,
488+
// which also disables Telegram's URL auto-linking inside <code>.
489+
if !strings.Contains(out, "<b>ALLOW</b>") {
490+
t.Errorf("policy show output must bold section headers: %q", out)
491+
}
492+
}
493+
494+
func TestPolicyShowEscapesHTML(t *testing.T) {
495+
s := newTestStore(t)
496+
if _, err := s.AddRule("deny", store.RuleOpts{
497+
Pattern: "<script>",
498+
Source: "manual",
499+
}); err != nil {
500+
t.Fatal(err)
501+
}
502+
if _, err := s.AddRule("redact", store.RuleOpts{
503+
Pattern: "a&b",
504+
Replacement: "<x>",
505+
}); err != nil {
506+
t.Fatal(err)
507+
}
508+
509+
handler := newTestHandlerWithStore(t, s, nil, "")
510+
out := handler.Handle(&Command{Name: "policy", Args: []string{"show"}})
511+
512+
// Raw "<script>" must not survive the <code> wrapper. It should be
513+
// rendered as "<code>pattern:&lt;script&gt;</code>".
514+
if strings.Contains(out, "pattern:<script>") {
515+
t.Errorf("raw <script> must be HTML-escaped: %s", out)
516+
}
517+
if !strings.Contains(out, "&lt;script&gt;") {
518+
t.Errorf("expected &lt;script&gt; in output: %s", out)
519+
}
520+
if !strings.Contains(out, "a&amp;b") {
521+
t.Errorf("expected a&amp;b in output: %s", out)
522+
}
486523
}
487524

488525
func TestPolicyRemoveThenRecompile(t *testing.T) {

0 commit comments

Comments
 (0)