From 6b3946f1d6573430e1a3f281f8e19a6949e55e4e Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky Date: Mon, 13 Apr 2026 13:48:43 +0800 Subject: [PATCH 1/2] 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
...
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
. Escape destination, tool, pattern,
  replacement, name, source, default verdict, and protocols.
- approval.go: sendReply path sniffs the 
 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.
---
 internal/telegram/approval.go      |  9 ++++++
 internal/telegram/commands.go      | 42 ++++++++++++++++++----------
 internal/telegram/commands_test.go | 45 +++++++++++++++++++++++++++---
 3 files changed, 78 insertions(+), 18 deletions(-)

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/commands.go b/internal/telegram/commands.go
index 0522de7..006f2a6 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,23 @@ func (h *CommandHandler) policyShow() string {
 	return b.String()
 }
 
+// htmlEscapeText escapes the three characters that Telegram's HTML parse mode
+// requires in text nodes: &, <, >. Quotes and apostrophes are left alone
+// since we don't use them in tag attributes.
+func htmlEscapeText(s string) string {
+	s = strings.ReplaceAll(s, "&", "&")
+	s = strings.ReplaceAll(s, "<", "<")
+	s = strings.ReplaceAll(s, ">", ">")
+	return s
+}
+
+// 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 "" + htmlEscapeText(s) + ""
+}
+
 func (h *CommandHandler) policyShowFromStore() string {
 	cfg, err := h.store.GetConfig()
 	if err != nil {
@@ -271,9 +286,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 +306,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 +316,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)", htmlEscapeText(r.Name))
 			}
 			if r.Source != "" {
-				fmt.Fprintf(&b, " [%s]", r.Source)
+				fmt.Fprintf(&b, " [%s]", htmlEscapeText(r.Source))
 			}
 			b.WriteString("\n")
 		}
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: "