diff --git a/internal/telegram/approval.go b/internal/telegram/approval.go index b86a598..a4a799b 100644 --- a/internal/telegram/approval.go +++ b/internal/telegram/approval.go @@ -434,6 +434,15 @@ func (tc *TelegramChannel) handleMessage(msg *tgbotapi.Message) { } reply := tgbotapi.NewMessage(tc.chatID, response) + // Responses that contain or are pre-rendered HTML (see + // policyShow / policyShowFromStore). Send them with HTML parse mode so + // the tags render and Telegram does not auto-link URLs inside . + // Plain-text responses never contain these tags because dynamic input + // gets HTML-escaped before insertion. + if strings.Contains(response, "") || strings.Contains(response, "") { + reply.ParseMode = tgbotapi.ModeHTML + reply.DisableWebPagePreview = true + } if _, err := tc.api.Send(reply); err != nil { log.Printf("telegram send error: %s", sanitizeError(err)) } diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 3fcd07b..983ec48 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -57,7 +57,7 @@ var protoDisplayName = map[string]string{ // Callers must set ParseMode to HTML when sending the message. func FormatApprovalMessage(req channel.ApprovalRequest) string { if req.Protocol == "mcp" { - msg := "OpenClaw wants to call tool:\n\n" + htmlEscape(req.Destination) + msg := "OpenClaw wants to call tool:\n\n" + htmlCode(req.Destination) if req.ToolArgs != "" { pretty := prettyJSONOrRaw(req.ToolArgs) msg += "\n\nArguments:\n
" + htmlEscape(pretty) + "
" @@ -69,20 +69,21 @@ func FormatApprovalMessage(req channel.ApprovalRequest) string { if display == "" { display = req.Protocol } + destPort := fmt.Sprintf("%s:%d", req.Destination, req.Port) if req.Method != "" { ver := "" if req.HTTPVersion != "" { ver = " (" + htmlEscape(req.HTTPVersion) + ")" } return fmt.Sprintf( - "OpenClaw wants to connect to:\n\n%s %s:%d\n%s %s%s\n\nAllow this request?", - htmlEscape(display), htmlEscape(req.Destination), req.Port, - htmlEscape(req.Method), htmlEscape(buildRequestURL(req)), ver, + "OpenClaw wants to connect to:\n\n%s %s\n%s %s%s\n\nAllow this request?", + htmlEscape(display), htmlCode(destPort), + htmlEscape(req.Method), htmlCode(buildRequestURL(req)), ver, ) } return fmt.Sprintf( - "OpenClaw wants to connect to:\n\n%s %s:%d\n\nAllow this connection?", - htmlEscape(display), htmlEscape(req.Destination), req.Port, + "OpenClaw wants to connect to:\n\n%s %s\n\nAllow this connection?", + htmlEscape(display), htmlCode(destPort), ) } diff --git a/internal/telegram/bot_test.go b/internal/telegram/bot_test.go index 0680f03..ce3381d 100644 --- a/internal/telegram/bot_test.go +++ b/internal/telegram/bot_test.go @@ -77,8 +77,8 @@ func TestFormatApprovalMessage(t *testing.T) { Protocol: "http", } msg := FormatApprovalMessage(req) - if !strings.Contains(msg, "HTTP example.com:8080") { - t.Errorf("expected 'HTTP example.com:8080' in message, got: %s", msg) + if !strings.Contains(msg, "HTTP example.com:8080") { + t.Errorf("expected 'HTTP example.com:8080' in message, got: %s", msg) } }) @@ -167,10 +167,10 @@ func TestFormatApprovalMessage(t *testing.T) { Path: "/users/me", } msg := FormatApprovalMessage(req) - if !strings.Contains(msg, "HTTPS api.example.com:443") { + if !strings.Contains(msg, "HTTPS api.example.com:443") { t.Errorf("expected destination line in message, got: %s", msg) } - if !strings.Contains(msg, "GET https://api.example.com/users/me") { + if !strings.Contains(msg, "GET https://api.example.com/users/me") { t.Errorf("expected request line in message, got: %s", msg) } if !strings.Contains(msg, "Allow this request?") { @@ -187,7 +187,7 @@ func TestFormatApprovalMessage(t *testing.T) { Path: "/api/submit", } msg := FormatApprovalMessage(req) - if !strings.Contains(msg, "POST http://localhost:8080/api/submit") { + if !strings.Contains(msg, "POST http://localhost:8080/api/submit") { t.Errorf("expected URL with explicit port, got: %s", msg) } }) @@ -201,7 +201,7 @@ func TestFormatApprovalMessage(t *testing.T) { Path: "", } msg := FormatApprovalMessage(req) - if !strings.Contains(msg, "HEAD https://example.com/") { + if !strings.Contains(msg, "HEAD https://example.com/") { t.Errorf("expected URL with default path '/', got: %s", msg) } }) diff --git a/internal/telegram/commands.go b/internal/telegram/commands.go index 0522de7..3a4f502 100644 --- a/internal/telegram/commands.go +++ b/internal/telegram/commands.go @@ -216,9 +216,7 @@ func (h *CommandHandler) policyShow() string { // Fallback to engine snapshot when store is not configured. snap := h.engine.Load().Snapshot() var b strings.Builder - b.WriteString("Current policy (default: ") - b.WriteString(snap.Default.String()) - b.WriteString(")\n\n") + fmt.Fprintf(&b, "Current policy (default: %s)\n\n", htmlCode(snap.Default.String())) for _, section := range []struct { label string @@ -235,14 +233,14 @@ func (h *CommandHandler) policyShow() string { b.WriteString(":\n") for _, r := range section.rules { b.WriteString(" ") - b.WriteString(r.Destination) + b.WriteString(htmlCode(r.Destination)) if len(r.Ports) > 0 { b.WriteString(" ports=") b.WriteString(formatPorts(r.Ports)) } if len(r.Protocols) > 0 { b.WriteString(" protocols=") - b.WriteString(strings.Join(r.Protocols, ",")) + b.WriteString(htmlCode(strings.Join(r.Protocols, ","))) } b.WriteString("\n") } @@ -255,6 +253,13 @@ func (h *CommandHandler) policyShow() string { return b.String() } +// htmlCode wraps s in ... after HTML-escaping it. Inside +// Telegram will not auto-detect URLs, so destinations like api.github.com +// render as monospace text rather than clickable blue links. +func htmlCode(s string) string { + return "" + htmlEscape(s) + "" +} + func (h *CommandHandler) policyShowFromStore() string { cfg, err := h.store.GetConfig() if err != nil { @@ -271,9 +276,7 @@ func (h *CommandHandler) policyShowFromStore() string { } var b strings.Builder - b.WriteString("Current policy (default: ") - b.WriteString(dv) - b.WriteString(")\n\n") + fmt.Fprintf(&b, "Current policy (default: %s)\n\n", htmlCode(dv)) for _, section := range []struct { label string @@ -293,8 +296,9 @@ func (h *CommandHandler) policyShowFromStore() string { if len(sectionRules) == 0 { continue } + b.WriteString("") b.WriteString(section.label) - b.WriteString(":\n") + b.WriteString(":\n") for _, r := range sectionRules { target := r.Destination if r.Tool != "" { @@ -302,23 +306,23 @@ func (h *CommandHandler) policyShowFromStore() string { } else if r.Pattern != "" { target = "pattern:" + r.Pattern } - fmt.Fprintf(&b, " [%d] %s", r.ID, target) + fmt.Fprintf(&b, " [%d] %s", r.ID, htmlCode(target)) if len(r.Ports) > 0 { b.WriteString(" ports=") b.WriteString(formatPorts(r.Ports)) } if len(r.Protocols) > 0 { b.WriteString(" protocols=") - b.WriteString(strings.Join(r.Protocols, ",")) + b.WriteString(htmlCode(strings.Join(r.Protocols, ","))) } if r.Replacement != "" { - fmt.Fprintf(&b, " -> %q", r.Replacement) + fmt.Fprintf(&b, " -> %s", htmlCode(r.Replacement)) } if r.Name != "" { - fmt.Fprintf(&b, " (%s)", r.Name) + fmt.Fprintf(&b, " (%s)", htmlEscape(r.Name)) } if r.Source != "" { - fmt.Fprintf(&b, " [%s]", r.Source) + fmt.Fprintf(&b, " [%s]", htmlEscape(r.Source)) } b.WriteString("\n") } @@ -350,14 +354,14 @@ func (h *CommandHandler) policyAllow(dest string) string { if err := h.recompileAndSwap(); err != nil { return fmt.Sprintf("Added allow rule but failed to recompile: %v", err) } - return fmt.Sprintf("Added allow rule: %s", dest) + return "Added allow rule: " + htmlCode(dest) } // Fallback to in-memory mutation when store is not configured. if err := h.engine.Load().AddAllowRule(dest); err != nil { //nolint:staticcheck // backward compat fallback when no store return fmt.Sprintf("Failed to add allow rule: %v", err) } - return fmt.Sprintf("Added allow rule: %s%s", dest, inMemoryWarning) + return "Added allow rule: " + htmlCode(dest) + inMemoryWarning } func (h *CommandHandler) policyDeny(dest string) string { @@ -375,13 +379,13 @@ func (h *CommandHandler) policyDeny(dest string) string { if err := h.recompileAndSwap(); err != nil { return fmt.Sprintf("Added deny rule but failed to recompile: %v", err) } - return fmt.Sprintf("Added deny rule: %s", dest) + return "Added deny rule: " + htmlCode(dest) } if err := h.engine.Load().AddDenyRule(dest); err != nil { //nolint:staticcheck // backward compat fallback when no store return fmt.Sprintf("Failed to add deny rule: %v", err) } - return fmt.Sprintf("Added deny rule: %s%s", dest, inMemoryWarning) + return "Added deny rule: " + htmlCode(dest) + inMemoryWarning } func (h *CommandHandler) policyRemove(idStr string) string { diff --git a/internal/telegram/commands_test.go b/internal/telegram/commands_test.go index b5e623b..9aeec7c 100644 --- a/internal/telegram/commands_test.go +++ b/internal/telegram/commands_test.go @@ -469,13 +469,13 @@ func TestPolicyShowIncludesAllFields(t *testing.T) { out := handler.Handle(&Command{Name: "policy", Args: []string{"show"}}) mustContain := []string{ - "example.com", + "example.com", "ports=443", - "protocols=quic", + "protocols=quic", "(test rule)", "[manual]", - "pattern:sk-[A-Za-z0-9]+", - `-> "sk-REDACTED"`, + "pattern:sk-[A-Za-z0-9]+", + "-> sk-REDACTED", "[seed]", } for _, want := range mustContain { @@ -483,6 +483,43 @@ func TestPolicyShowIncludesAllFields(t *testing.T) { t.Errorf("policy show output missing %q\nfull output:\n%s", want, out) } } + + // Section headers are bolded so the sender picks HTML parse mode, + // which also disables Telegram's URL auto-linking inside . + if !strings.Contains(out, "ALLOW") { + t.Errorf("policy show output must bold section headers: %q", out) + } +} + +func TestPolicyShowEscapesHTML(t *testing.T) { + s := newTestStore(t) + if _, err := s.AddRule("deny", store.RuleOpts{ + Pattern: "