diff --git a/docs/bridge.md b/docs/bridge.md index d821f72fb4..e2b1af229b 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -190,6 +190,16 @@ A relay channel with **no chat platform of its own**. Outbound messages and ques - Relay frames are authenticated with HTTP Basic (the credential as password) and `202 Accepted` is the only success status. Attachments relay as **metadata only** (`fileName`, `mimeType`, `size`) — never content. - Groups / `@mention` gating don't apply; `POST /router/config/groups` rejects this channel explicitly. +## Outbound prose rendering + +Agent replies are authored as GFM (GitHub-flavored Markdown) — headings, bold/italic, links, lists, tables, fenced code. Each adapter's `Send` renders that same `Outbound.Text` into whatever markup dialect its platform actually understands, with automatic degradation to plain text if rendering is rejected. The shared, stdlib-only chunking and conversion helpers live in `internal/bridge/markdown`. + +- **Slack**: rendered as Block Kit `markdown` blocks (real GFM parsing, unlike the legacy mrkdwn `text` field, which cannot represent headings, tables, or fenced code with syntax highlighting). Text is chunked to Slack's 12,000-character cumulative budget across all blocks in one payload (`internal/bridge/markdown.BuildBlockChunks`, 3,000-char per-block target). The top-level `text` field is still sent alongside the blocks as the notification/accessibility fallback. If Slack rejects the blocks with an unambiguous block-capability error (`invalid_blocks`, `invalid_block`, `blocks_too_long`, `msg_blocks_too_long`, `invalid_block_id`), the adapter retries as plain text and sets a sticky per-identity latch so subsequent sends skip the blocks attempt entirely. A more ambiguous error (`invalid_arguments`, which Slack also returns for unrelated reasons) still retries as plain text for that one send but does **not** latch — the next send tries blocks again. +- **Telegram**: rendered as Telegram HTML (`internal/bridge/markdown.ToTelegramHTML`), the restricted tag set Telegram's `ParseMode: HTML` supports (``, ``, ``, ``/`
`, `
`, etc.) — chosen over MarkdownV2 because it requires escaping only three characters instead of ~18 reserved ones. Telegram also forbids `code`/`pre` entities nested inside any other entity (the sole exception being `
`), so a code span landing inside a heading, emphasis, link label, blockquote or table cell is emitted as plain escaped text rather than a nested `` tag. Source markdown is chunked at 3,500 characters (`MarkdownChunkLimit`) before conversion — headroom under Telegram's 4,096-character *post-parse* cap, which HTML tags and `&`-style escaping do not count against. If a chunk's HTML is rejected with a parse/entity error — or with `message is too long`, which the `---` → 10-em-dash expansion can still provoke — that one chunk is retried unformatted (no `ParseMode`) — there is no sticky latch, since a parse failure is specific to that chunk's content, not a platform capability.
+- **Mattermost**: unchanged — `Post.Message` is sent as native GFM and Mattermost's own server-side parser already renders it correctly.
+
+The `RichRenderer` tool-card paths (`internal/bridge/slack/render.go`, `internal/bridge/telegram/render.go`) that hand-author Block Kit / legacy Markdown for tool calls, lists, tables, and status previews are separate code paths, untouched by the above — they compose their own markup directly rather than converting agent-authored GFM.
+
 ## HTTP API (`/router/*`)
 
 All endpoints live on the existing opencode API port. Bare paths (`/send`, `/identities/*`, `/config/groups`) return 404 — everything is under `/router/*`.
diff --git a/internal/bridge/markdown/markdown.go b/internal/bridge/markdown/markdown.go
new file mode 100644
index 0000000000..06b829914e
--- /dev/null
+++ b/internal/bridge/markdown/markdown.go
@@ -0,0 +1,401 @@
+// Package markdown provides shared, stdlib-only markdown utilities used by
+// the chat-bridge adapters (Slack and Telegram). It has no dependency on
+// internal/bridge or any platform SDK — mirroring the dependency-free
+// discipline of internal/bridge itself — so it can be imported by every
+// platform adapter package without an import cycle.
+//
+// Two responsibilities live here (a third, the GFM -> Telegram-HTML
+// converter, lives in telegram.go):
+//
+//   - NormalizeSlackLinks rewrites Slack mrkdwn's own link syntax
+//     ( / ) into standard Markdown
+//     ([label](https://x) / https://x) without disturbing <@U123>-style
+//     mentions or incidental angle-bracket text.
+//   - Split is a fence-aware chunker that splits long markdown text into
+//     platform-sized pieces without ever cutting inside an open ``` fence,
+//     never splitting a multi-byte UTF-8 rune, and packing the final
+//     permitted chunk to the limit (appending a visible truncation marker)
+//     when a hard chunk-count cap is reached with content still remaining.
+package markdown
+
+import (
+	"math"
+	"regexp"
+	"strings"
+	"unicode/utf8"
+)
+
+// TruncationMarker is appended to the last emitted chunk when content is
+// dropped because a caller-supplied chunk-count limit was reached with
+// text still remaining. Italic prose visually distinguishes it from
+// agent-authored content. Uses an explicit \u2026 escape (not a literal
+// … glyph) so the source file stays plain ASCII per repo convention.
+const TruncationMarker = "\n\n_\u2026truncated\u2026_"
+
+// DefaultPayloadBudget and DefaultBlockTarget are the fallback values
+// BuildBlockChunks clamps to when the caller passes a non-positive or
+// otherwise unusable budget/target. They mirror Slack's documented
+// markdown-block limits (see the slack package's own constants, which are
+// defined independently to avoid a markdown -> slack import).
+const (
+	DefaultPayloadBudget = 12_000
+	DefaultBlockTarget   = 3_000
+)
+
+// slackLinkPattern matches Slack mrkdwn's own link syntax: 
+// or bare . Anchored on a URL scheme so `<@U123>` mentions,
+// `<#C123|chan>` channel references, ``, and incidental angle-bracket
+// text (e.g. "a < b && b > c") are never matched — only content that starts
+// with http:// or https:// immediately after the opening `<` qualifies.
+var slackLinkPattern = regexp.MustCompile(`<(https?://[^<>|]+)(?:\|([^<>]*))?>`)
+
+// NormalizeSlackLinks rewrites Slack mrkdwn's own link syntax into standard
+// Markdown so it renders correctly through a parser that only understands
+// GFM-style links (e.g. Slack's `markdown` block type). ``
+// becomes `[label](https://x)`; `` (or `` — an empty
+// label) becomes the bare URL `https://x`. Non-URL angle-bracket content
+// (user/channel mentions, ``, or incidental `<`/`>` characters) is
+// left untouched.
+func NormalizeSlackLinks(s string) string {
+	if !strings.Contains(s, "<") {
+		return s
+	}
+	return slackLinkPattern.ReplaceAllStringFunc(s, func(m string) string {
+		sub := slackLinkPattern.FindStringSubmatch(m)
+		url := sub[1]
+		label := strings.TrimSpace(sub[2])
+		if label == "" {
+			return url
+		}
+		return "[" + label + "](" + url + ")"
+	})
+}
+
+// fenceState tracks whether we are currently inside an open ``` fence,
+// the length of the backtick run that opened it (a fence is only closed by
+// a run of at least that many backticks — CommonMark's own rule, which
+// also means a SHORTER backtick run inside an open fence is just content,
+// not a toggle), and the fence's info string (e.g. "python").
+type fenceState struct {
+	open      bool
+	markerLen int
+	info      string
+}
+
+// maxFenceInfoRunes bounds the info string retained for the purpose of
+// REOPENING a fence on the far side of a chunk boundary. A real info string
+// is a language tag ("go", "json", "python"); text far longer than that is
+// not a tag at all, and carrying it verbatim into the synthetic reopen
+// delimiter would let the delimiter rival the chunk limit itself — which
+// made Split spin forever, since the reopen text is pushed back onto the
+// unconsumed remainder. Clamping affects only the synthetic delimiter; the
+// original opening line is always emitted untouched.
+const maxFenceInfoRunes = 64
+
+// clampRunes truncates s to at most n runes, always cutting on a codepoint
+// boundary.
+func clampRunes(s string, n int) string {
+	if n <= 0 {
+		return ""
+	}
+	count := 0
+	for i := range s {
+		if count == n {
+			return s[:i]
+		}
+		count++
+	}
+	return s
+}
+
+// scanFence walks s line by line, updating fs for every fence-toggling line
+// it finds (a line whose left-trimmed content starts with 3+ backticks).
+// It is meant to be called incrementally, once per emitted chunk, so state
+// carries forward correctly across chunk boundaries.
+func scanFence(fs *fenceState, s string) {
+	for _, line := range strings.Split(s, "\n") {
+		trimmed := strings.TrimLeft(line, " \t")
+		n := countLeadingBackticks(trimmed)
+		if n < 3 {
+			continue
+		}
+		if !fs.open {
+			fs.open = true
+			fs.markerLen = n
+			fs.info = clampRunes(strings.TrimSpace(trimmed[n:]), maxFenceInfoRunes)
+			continue
+		}
+		// Already inside a fence: only a run at least as long as the
+		// opening one closes it. A shorter run is literal content.
+		if n >= fs.markerLen {
+			fs.open = false
+			fs.markerLen = 0
+			fs.info = ""
+		}
+	}
+}
+
+func countLeadingBackticks(s string) int {
+	n := 0
+	for n < len(s) && s[n] == '`' {
+		n++
+	}
+	return n
+}
+
+// fenceCloseText returns the text to append to head to close the fence
+// described by fs. It adds a leading newline only if head doesn't already
+// end with one, so the closing delimiter always starts its own line.
+func fenceCloseText(head string, fs fenceState) string {
+	if head != "" && !strings.HasSuffix(head, "\n") {
+		return "\n" + strings.Repeat("`", fs.markerLen)
+	}
+	return strings.Repeat("`", fs.markerLen)
+}
+
+// fenceOpenText returns the text to prepend to the next chunk to reopen
+// the fence described by fs, preserving its original info string.
+func fenceOpenText(fs fenceState) string {
+	return strings.Repeat("`", fs.markerLen) + fs.info + "\n"
+}
+
+var headingLineRe = regexp.MustCompile(`(?m)^#{1,6} `)
+
+// findBoundary picks the best cut point for remaining, at or before limit
+// runes, in priority order: blank line, heading line, any newline, falling
+// through to a hard-wrap at exactly limit runes when no candidate retains
+// at least half of limit. The returned value is a rune index into
+// remaining (NOT a byte offset).
+func findBoundary(remaining string, limit int) int {
+	runeLen := utf8.RuneCountInString(remaining)
+	if limit >= runeLen {
+		return runeLen
+	}
+	prefixByteLen := runeIndexToByte(remaining, limit)
+	prefix := remaining[:prefixByteLen]
+	half := limit / 2
+
+	if idx := strings.LastIndex(prefix, "\n\n"); idx >= 0 {
+		cut := idx + 2
+		if rc := utf8.RuneCountInString(remaining[:cut]); rc >= half {
+			return rc
+		}
+	}
+
+	if locs := headingLineRe.FindAllStringIndex(prefix, -1); len(locs) > 0 {
+		last := locs[len(locs)-1]
+		cut := last[0]
+		if cut > 0 {
+			if rc := utf8.RuneCountInString(remaining[:cut]); rc >= half {
+				return rc
+			}
+		}
+	}
+
+	if idx := strings.LastIndex(prefix, "\n"); idx >= 0 {
+		cut := idx + 1
+		if rc := utf8.RuneCountInString(remaining[:cut]); rc >= half {
+			return rc
+		}
+	}
+
+	return limit
+}
+
+// runeIndexToByte returns the byte offset of the n-th rune in s (or
+// len(s) if s has fewer than n runes).
+func runeIndexToByte(s string, n int) int {
+	if n <= 0 {
+		return 0
+	}
+	count := 0
+	for i := range s {
+		if count == n {
+			return i
+		}
+		count++
+	}
+	return len(s)
+}
+
+// buildChunk cuts remRunes at rune index cut, closing any fence the cut
+// broke (per fs, the fence state carried in from prior chunks). If the
+// closing overhead (plus the caller's reserve, e.g. room for a truncation
+// marker) would push the chunk's rune length past limit, cut is shrunk and
+// the fence re-scanned until it fits — this is what guarantees every
+// chunk, including ones with a broken-fence close appended, stays within
+// limit runes.
+//
+// Returns the finalized chunk text (head), the fence state to carry into
+// the next chunk (already "closed" if a close/reopen fixup happened —
+// scanning the next chunk's reopening delimiter line naturally reopens
+// it), the text to prepend to the next chunk (empty unless a fence was
+// reopened), and the rune index actually consumed from remRunes (<= cut;
+// only differs from the input cut when shrinking occurred).
+func buildChunk(remRunes []rune, cut, limit, reserve int, fs fenceState) (head string, newFS fenceState, reopen string, usedCut int) {
+	if cut > len(remRunes) {
+		cut = len(remRunes)
+	}
+	if cut < 0 {
+		cut = 0
+	}
+	for {
+		h := string(remRunes[:cut])
+		fsCopy := fs
+		scanFence(&fsCopy, h)
+
+		closing := ""
+		reopenText := ""
+		finalFS := fsCopy
+		if fsCopy.open {
+			closing = fenceCloseText(h, fsCopy)
+			reopenText = fenceOpenText(fsCopy)
+			finalFS = fenceState{}
+		}
+
+		total := utf8.RuneCountInString(h) + utf8.RuneCountInString(closing) + reserve
+		if total <= limit || cut == 0 {
+			return h + closing, finalFS, reopenText, cut
+		}
+		overflow := total - limit
+		next := cut - overflow
+		if next >= cut {
+			next = cut - 1
+		}
+		cut = next
+		if cut < 0 {
+			cut = 0
+		}
+	}
+}
+
+// Split divides text into chunks of at most limit runes each, never
+// cutting inside an open ``` fence (closing and reopening it across the
+// boundary instead) and never splitting a multi-byte UTF-8 rune. It
+// prefers blank-line, then heading-line, then any-newline cut points,
+// falling back to a hard rune-wrap when no such boundary retains at least
+// half of limit.
+//
+// maxChunks caps the number of chunks emitted; <= 0 means unlimited. When
+// the cap is reached with text still remaining, the final chunk is packed
+// to the limit (rather than stopping at a tidy boundary) and marker is
+// appended within budget — the marker's own rune length, and any fence
+// the final cut broke, are reserved for BEFORE the cut is made, so the
+// marker is never pushed over limit and never renders inside a code
+// fence. truncated is true exactly when this happened.
+//
+// Whitespace-only input returns (nil, false).
+func Split(text string, limit, maxChunks int, marker string) ([]string, bool) {
+	if strings.TrimSpace(text) == "" {
+		return nil, false
+	}
+	if limit <= 0 {
+		limit = 1
+	}
+	if utf8.RuneCountInString(text) <= limit {
+		return []string{text}, false
+	}
+
+	markerLen := utf8.RuneCountInString(marker)
+	var chunks []string
+	remaining := text
+	fs := fenceState{}
+	truncated := false
+
+	for utf8.RuneCountInString(remaining) > limit {
+		remRunes := []rune(remaining)
+		isLast := maxChunks > 0 && len(chunks)+1 >= maxChunks
+
+		if isLast {
+			cut := limit - markerLen
+			head, _, _, _ := buildChunk(remRunes, cut, limit, markerLen, fs)
+			chunks = append(chunks, head+marker)
+			truncated = true
+			remaining = ""
+			break
+		}
+
+		cut := findBoundary(remaining, limit)
+		head, newFS, reopen, usedCut := buildChunk(remRunes, cut, limit, 0, fs)
+
+		// Forward-progress guarantee. `reopen` is synthetic text pushed
+		// BACK onto the unconsumed remainder, so this loop only terminates
+		// if every pass consumes strictly more than it pushes back. Two
+		// pathological shapes break that: a cut shrunk all the way to zero
+		// (a limit smaller than the fence delimiter itself), and a reopen
+		// delimiter at least as long as the slice consumed. Either one used
+		// to spin forever while growing `remaining` without bound. Give up
+		// the close/reopen fixup for this one boundary instead: the content
+		// is still emitted verbatim and in full, only the fence's syntax
+		// highlighting is lost across the split.
+		if usedCut <= 0 || utf8.RuneCountInString(reopen) >= usedCut {
+			hardCut := cut
+			if hardCut < 1 {
+				hardCut = 1
+			}
+			if hardCut > len(remRunes) {
+				hardCut = len(remRunes)
+			}
+			head = string(remRunes[:hardCut])
+			newFS = fs
+			scanFence(&newFS, head)
+			reopen = ""
+			usedCut = hardCut
+		}
+
+		chunks = append(chunks, head)
+		fs = newFS
+		remaining = reopen + string(remRunes[usedCut:])
+	}
+
+	if remaining != "" {
+		chunks = append(chunks, remaining)
+	}
+
+	return chunks, truncated
+}
+
+// BuildBlockChunks is the Slack-facing helper: it normalizes text via
+// NormalizeSlackLinks, derives a per-chunk rune limit from the payload
+// budget and per-block target (blockCount = ceil(payloadBudget /
+// perBlockTarget); perChunkLimit = floor(payloadBudget / blockCount), so
+// blockCount * perChunkLimit never exceeds payloadBudget), and calls
+// Split with that limit and blockCount as the chunk cap. The result
+// always satisfies sum(runeLen(chunk)) <= payloadBudget.
+//
+// Non-positive payloadBudget clamps to DefaultPayloadBudget; non-positive
+// or oversized perBlockTarget (bigger than the budget) clamps to
+// DefaultBlockTarget (itself capped to the budget).
+func BuildBlockChunks(text string, payloadBudget, perBlockTarget int, marker string) ([]string, bool) {
+	if payloadBudget <= 0 {
+		payloadBudget = DefaultPayloadBudget
+	}
+	if perBlockTarget <= 0 || perBlockTarget > payloadBudget {
+		perBlockTarget = DefaultBlockTarget
+		if perBlockTarget > payloadBudget {
+			perBlockTarget = payloadBudget
+		}
+	}
+
+	normalized := NormalizeSlackLinks(text)
+	if strings.TrimSpace(normalized) == "" {
+		return nil, false
+	}
+
+	blockCount := int(math.Ceil(float64(payloadBudget) / float64(perBlockTarget)))
+	if blockCount < 1 {
+		blockCount = 1
+	}
+	perChunkLimit := payloadBudget / blockCount // floor division
+	if perChunkLimit < 1 {
+		// Budget smaller than blockCount: shrink blockCount so the
+		// sum(chunkLengths) <= payloadBudget guarantee still holds.
+		perChunkLimit = 1
+		blockCount = payloadBudget
+		if blockCount < 1 {
+			blockCount = 1
+		}
+	}
+
+	return Split(normalized, perChunkLimit, blockCount, marker)
+}
diff --git a/internal/bridge/markdown/markdown_progress_test.go b/internal/bridge/markdown/markdown_progress_test.go
new file mode 100644
index 0000000000..7192ee02f9
--- /dev/null
+++ b/internal/bridge/markdown/markdown_progress_test.go
@@ -0,0 +1,126 @@
+package markdown
+
+import (
+	"strings"
+	"testing"
+	"time"
+	"unicode/utf8"
+)
+
+// splitWithTimeout runs Split on a watchdog. Split used to be able to spin
+// forever on inputs where the synthetic fence-reopen delimiter was at least
+// as long as the slice consumed per pass, so a plain call would hang the
+// whole test binary rather than fail it.
+func splitWithTimeout(t *testing.T, text string, limit, maxChunks int, marker string) []string {
+	t.Helper()
+	type result struct {
+		chunks []string
+	}
+	done := make(chan result, 1)
+	go func() {
+		c, _ := Split(text, limit, maxChunks, marker)
+		done <- result{chunks: c}
+	}()
+	select {
+	case r := <-done:
+		return r.chunks
+	case <-time.After(5 * time.Second):
+		t.Fatalf("Split did not terminate within 5s (limit=%d, maxChunks=%d) — forward-progress regression", limit, maxChunks)
+		return nil
+	}
+}
+
+// TestSplitTerminatesOnLongFenceInfoString covers the non-termination bug:
+// a fence whose info string rivals the chunk limit produced a reopen
+// delimiter longer than the content consumed, so `remaining` grew every
+// pass instead of shrinking.
+func TestSplitTerminatesOnLongFenceInfoString(t *testing.T) {
+	cases := []struct {
+		name  string
+		text  string
+		limit int
+	}{
+		{
+			name:  "info string longer than limit",
+			text:  "```" + strings.Repeat("x", 200) + "\ncode body here\n```\ntail",
+			limit: 100,
+		},
+		{
+			name:  "info string near the telegram limit",
+			text:  "```" + strings.Repeat("x", 3600) + "\ncode\n```\ntail",
+			limit: 3500,
+		},
+		{
+			name:  "limit smaller than the fence delimiter itself",
+			text:  "````\nabc",
+			limit: 4,
+		},
+		{
+			name:  "limit of one rune with an open fence",
+			text:  "```go\nabcdef\n```",
+			limit: 1,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			chunks := splitWithTimeout(t, tc.text, tc.limit, 0, TruncationMarker)
+			if len(chunks) == 0 {
+				t.Fatal("Split returned no chunks")
+			}
+			for i, c := range chunks {
+				if !utf8.ValidString(c) {
+					t.Errorf("chunk %d is not valid UTF-8: %q", i, c)
+				}
+				if n := utf8.RuneCountInString(c); n > tc.limit {
+					t.Errorf("chunk %d has %d runes, limit is %d: %q", i, n, tc.limit, c)
+				}
+			}
+		})
+	}
+}
+
+// TestSplitPreservesEveryWordOnLongFenceInfoString asserts the
+// non-termination fix degrades formatting only — never content.
+func TestSplitPreservesEveryWordOnLongFenceInfoString(t *testing.T) {
+	body := "alpha bravo charlie delta echo foxtrot golf hotel india juliet"
+	text := "```" + strings.Repeat("x", 200) + "\n" + body + "\n```\ntail words here"
+
+	chunks := splitWithTimeout(t, text, 100, 0, TruncationMarker)
+
+	joined := strings.Join(chunks, "")
+	for _, w := range strings.Fields(body + " tail words here") {
+		if !strings.Contains(joined, w) {
+			t.Errorf("word %q lost across chunk boundaries; joined = %q", w, joined)
+		}
+	}
+}
+
+// TestSplitClampsReopenedFenceInfoString documents that the info string is
+// clamped only on the SYNTHETIC reopen delimiter — the original opening
+// line always survives verbatim.
+func TestSplitClampsReopenedFenceInfoString(t *testing.T) {
+	info := strings.Repeat("y", maxFenceInfoRunes+40)
+	text := "```" + info + "\n" + strings.Repeat("code line\n", 40) + "```"
+
+	chunks := splitWithTimeout(t, text, 200, 0, TruncationMarker)
+	if len(chunks) < 2 {
+		t.Fatalf("expected the input to split, got %d chunk(s)", len(chunks))
+	}
+	if !strings.Contains(chunks[0], "```"+info) {
+		t.Errorf("first chunk lost the original opening delimiter: %q", chunks[0])
+	}
+	for i, c := range chunks[1:] {
+		for _, line := range strings.Split(c, "\n") {
+			trimmed := strings.TrimLeft(line, " \t")
+			n := countLeadingBackticks(trimmed)
+			if n < 3 {
+				continue
+			}
+			if got := utf8.RuneCountInString(strings.TrimSpace(trimmed[n:])); got > maxFenceInfoRunes {
+				t.Errorf("chunk %d reopened a fence with a %d-rune info string, max is %d",
+					i+1, got, maxFenceInfoRunes)
+			}
+		}
+	}
+}
diff --git a/internal/bridge/markdown/markdown_test.go b/internal/bridge/markdown/markdown_test.go
new file mode 100644
index 0000000000..6792322abc
--- /dev/null
+++ b/internal/bridge/markdown/markdown_test.go
@@ -0,0 +1,361 @@
+package markdown
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+	"testing"
+	"unicode/utf8"
+)
+
+// --- NormalizeSlackLinks -----------------------------------------------
+
+func TestNormalizeSlackLinks(t *testing.T) {
+	tests := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{
+			name: "labelled link",
+			in:   "Triggered  on `c2`.",
+			want: "Triggered [MySQL #12](https://tc/build) on `c2`.",
+		},
+		{
+			name: "bare link",
+			in:   "See  for details.",
+			want: "See https://gitlab.com/piano/x/-/merge_requests/1 for details.",
+		},
+		{
+			name: "empty label",
+			in:   "",
+			want: "https://example.com",
+		},
+		{
+			name: "two labelled links",
+			in:   " and ",
+			want: "[A](https://a.test) and [B](https://b.test)",
+		},
+		{
+			name: "already-GFM content untouched",
+			in:   "# Title\n\n- [MR !1](https://gitlab.com/x/-/merge_requests/1)\n- **bold**",
+			want: "# Title\n\n- [MR !1](https://gitlab.com/x/-/merge_requests/1)\n- **bold**",
+		},
+		{
+			name: "incidental angle brackets untouched",
+			in:   "if a < b && b > c then ",
+			want: "if a < b && b > c then ",
+		},
+		{
+			name: "user mention untouched",
+			in:   "cc <@U123456> please review",
+			want: "cc <@U123456> please review",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := NormalizeSlackLinks(tt.in)
+			if got != tt.want {
+				t.Errorf("NormalizeSlackLinks(%q) = %q, want %q", tt.in, got, tt.want)
+			}
+		})
+	}
+}
+
+// --- Split ---------------------------------------------------------------
+
+func TestSplit_FitsWithinLimitReturnsVerbatim(t *testing.T) {
+	text := "hello world, this fits easily."
+	chunks, truncated := Split(text, 1000, 0, TruncationMarker)
+	if truncated {
+		t.Fatalf("truncated = true, want false")
+	}
+	if len(chunks) != 1 || chunks[0] != text {
+		t.Fatalf("chunks = %#v, want single verbatim chunk", chunks)
+	}
+}
+
+func TestSplit_WhitespaceOnlyInputReturnsNil(t *testing.T) {
+	chunks, truncated := Split("   \n\t  ", 10, 0, TruncationMarker)
+	if chunks != nil || truncated {
+		t.Fatalf("chunks=%#v truncated=%v, want (nil, false)", chunks, truncated)
+	}
+	chunks, truncated = Split("", 10, 0, TruncationMarker)
+	if chunks != nil || truncated {
+		t.Fatalf("chunks=%#v truncated=%v, want (nil, false) for empty input", chunks, truncated)
+	}
+}
+
+func TestSplit_FencesBalancedAcrossChunks(t *testing.T) {
+	text := buildParagraphsWithFence(40, 60)
+	chunks, _ := Split(text, 120, 0, TruncationMarker)
+	if len(chunks) < 2 {
+		t.Fatalf("expected multiple chunks, got %d", len(chunks))
+	}
+	for i, c := range chunks {
+		if n := countBacktickToggles(c); n%2 != 0 {
+			t.Errorf("chunk %d has odd fence-toggle count %d (not independently valid): %q", i, n, c)
+		}
+	}
+}
+
+func TestSplit_NoContentLossWhenNotTruncated(t *testing.T) {
+	text := buildParagraphsWithFence(40, 60)
+	chunks, truncated := Split(text, 120, 0, TruncationMarker)
+	if truncated {
+		t.Fatalf("truncated = true, want false (maxChunks unlimited)")
+	}
+	var got []string
+	for _, c := range chunks {
+		got = append(got, wordsSkippingFenceLines(c)...)
+	}
+	want := wordsSkippingFenceLines(text)
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("word list mismatch after chunking:\ngot:  %v\nwant: %v", got, want)
+	}
+}
+
+func TestSplit_FenceSpanningBoundaryReopensWithSameInfo(t *testing.T) {
+	intro := "Intro paragraph with some words here to fill the buffer nicely."
+	var codeLines strings.Builder
+	for i := 0; i < 60; i++ {
+		codeLines.WriteString(fmt.Sprintf("line %02d of code content here\n", i))
+	}
+	text := intro + "\n\n```python\n" + codeLines.String() + "```\n\nOutro paragraph text."
+
+	chunks, truncated := Split(text, 200, 0, TruncationMarker)
+	if truncated {
+		t.Fatalf("truncated = true, want false")
+	}
+	if len(chunks) < 2 {
+		t.Fatalf("expected the fence to force a split, got %d chunk(s)", len(chunks))
+	}
+
+	foundReopen := false
+	for i := 0; i < len(chunks)-1; i++ {
+		lines := strings.Split(chunks[i], "\n")
+		last := strings.TrimSpace(lines[len(lines)-1])
+		if last != "```" {
+			continue
+		}
+		nextFirst := strings.SplitN(chunks[i+1], "\n", 2)[0]
+		if nextFirst != "```python" {
+			t.Errorf("chunk %d closes a fence but chunk %d reopens as %q, want %q", i, i+1, nextFirst, "```python")
+		}
+		foundReopen = true
+	}
+	if !foundReopen {
+		t.Fatalf("no chunk boundary closed+reopened the fence; test fixture/limit needs adjustment")
+	}
+
+	// Every chunk must independently be fence-balanced.
+	for i, c := range chunks {
+		if n := countBacktickToggles(c); n%2 != 0 {
+			t.Errorf("chunk %d not fence-balanced (toggles=%d): %q", i, n, c)
+		}
+	}
+}
+
+func TestSplit_MaxChunksHardCap(t *testing.T) {
+	var sb strings.Builder
+	for i := 0; i < 30; i++ {
+		sb.WriteString(strings.Repeat("word ", 20))
+		sb.WriteString("\n\n")
+	}
+	text := sb.String()
+	limit := 100
+
+	chunks, truncated := Split(text, limit, 3, TruncationMarker)
+	if len(chunks) != 3 {
+		t.Fatalf("len(chunks) = %d, want 3 (hard cap)", len(chunks))
+	}
+	if !truncated {
+		t.Fatalf("truncated = false, want true")
+	}
+	last := chunks[len(chunks)-1]
+	if !strings.HasSuffix(last, TruncationMarker) {
+		t.Errorf("last chunk does not end with marker: %q", last)
+	}
+	lastRunes := utf8.RuneCountInString(last)
+	if lastRunes > limit {
+		t.Errorf("last chunk rune length %d exceeds limit %d", lastRunes, limit)
+	}
+	if lastRunes <= limit/2 {
+		t.Errorf("last chunk rune length %d did not pack near the limit (limit/2=%d)", lastRunes, limit/2)
+	}
+	for i, c := range chunks {
+		if n := utf8.RuneCountInString(c); n > limit {
+			t.Errorf("chunk %d rune length %d exceeds limit %d", i, n, limit)
+		}
+	}
+}
+
+func TestSplit_RuneSafetyMultiByte(t *testing.T) {
+	text := strings.Repeat("h\u00e9llo \U0001F600 w\u00f6rld ", 900)
+	limit := 200
+
+	chunks, truncated := Split(text, limit, 0, TruncationMarker)
+	if truncated {
+		t.Fatalf("truncated = true, want false (maxChunks unlimited)")
+	}
+	if len(chunks) < 2 {
+		t.Fatalf("expected multiple chunks for a long repeated string")
+	}
+	for i, c := range chunks {
+		if !utf8.ValidString(c) {
+			t.Errorf("chunk %d is not valid UTF-8: %q", i, c)
+		}
+		if n := utf8.RuneCountInString(c); n > limit {
+			t.Errorf("chunk %d rune length %d exceeds limit %d", i, n, limit)
+		}
+	}
+}
+
+func TestSplit_TruncationMarkerNeverInsideFence(t *testing.T) {
+	var body strings.Builder
+	for i := 0; i < 500; i++ {
+		body.WriteString("x\n")
+	}
+	text := "```go\n" + body.String() + "```\n"
+	total := utf8.RuneCountInString(text)
+
+	for limit := 5; limit <= total; limit++ {
+		chunks, truncated := Split(text, limit, 1, TruncationMarker)
+		if len(chunks) == 0 {
+			continue
+		}
+		last := chunks[len(chunks)-1]
+		if truncated && !strings.HasSuffix(last, TruncationMarker) {
+			t.Fatalf("limit=%d: truncated chunk missing marker: %q", limit, tail(last, 40))
+		}
+		if n := countBacktickToggles(last); n%2 != 0 {
+			t.Fatalf("limit=%d: unbalanced fence around marker (toggles=%d): %q", limit, n, tail(last, 60))
+		}
+	}
+}
+
+// --- BuildBlockChunks ------------------------------------------------------
+
+func TestBuildBlockChunks_TruncatesWithinBudget(t *testing.T) {
+	text := strings.Repeat("word ", 3000) // ~15,000 runes, over the 12,000 budget
+	chunks, truncated := BuildBlockChunks(text, 12000, 3000, TruncationMarker)
+	if !truncated {
+		t.Fatalf("truncated = false, want true")
+	}
+	if len(chunks) > 4 {
+		t.Fatalf("len(chunks) = %d, want <= 4", len(chunks))
+	}
+	sum := 0
+	for _, c := range chunks {
+		sum += utf8.RuneCountInString(c)
+	}
+	if sum > 12000 {
+		t.Errorf("sum(runeLen) = %d, want <= 12000", sum)
+	}
+	if !strings.HasSuffix(chunks[len(chunks)-1], TruncationMarker) {
+		t.Errorf("last chunk does not end with truncation marker")
+	}
+}
+
+func TestBuildBlockChunks_ShortInputSingleChunkNoTruncation(t *testing.T) {
+	chunks, truncated := BuildBlockChunks("hello **world**", 12000, 3000, TruncationMarker)
+	if truncated {
+		t.Fatalf("truncated = true, want false")
+	}
+	if len(chunks) != 1 || chunks[0] != "hello **world**" {
+		t.Fatalf("chunks = %#v, want single verbatim chunk", chunks)
+	}
+}
+
+func TestBuildBlockChunks_NormalizesLinks(t *testing.T) {
+	chunks, _ := BuildBlockChunks("", 12000, 3000, TruncationMarker)
+	if len(chunks) != 1 || chunks[0] != "[y](https://x.test)" {
+		t.Fatalf("chunks = %#v, want normalized link", chunks)
+	}
+}
+
+func TestBuildBlockChunks_ClampsNonPositiveBudget(t *testing.T) {
+	chunks, truncated := BuildBlockChunks("hello", 0, 0, TruncationMarker)
+	if truncated || len(chunks) != 1 || chunks[0] != "hello" {
+		t.Fatalf("zero budget: chunks=%#v truncated=%v, want single verbatim chunk", chunks, truncated)
+	}
+	chunks, truncated = BuildBlockChunks("hello", -5, -5, TruncationMarker)
+	if truncated || len(chunks) != 1 || chunks[0] != "hello" {
+		t.Fatalf("negative budget: chunks=%#v truncated=%v, want single verbatim chunk", chunks, truncated)
+	}
+}
+
+// --- test helpers ----------------------------------------------------------
+
+// buildParagraphsWithFence constructs a multi-paragraph markdown document
+// (blank-line separated) containing one fenced code block, long enough
+// that a small limit forces multiple chunks.
+func buildParagraphsWithFence(paragraphs, wordsPerParagraph int) string {
+	var sb strings.Builder
+	for i := 0; i < paragraphs; i++ {
+		for j := 0; j < wordsPerParagraph; j++ {
+			fmt.Fprintf(&sb, "p%dw%d ", i, j)
+			// Wrap every few words so no single line is long enough to
+			// force a hard-wrap (which is explicitly allowed to split a
+			// line's content and would legitimately break the
+			// word-preservation invariant this fixture is meant to
+			// exercise via the newline/blank-line boundary tiers only).
+			if (j+1)%8 == 0 {
+				sb.WriteString("\n")
+			}
+		}
+		sb.WriteString("\n\n")
+		if i == paragraphs/2 {
+			sb.WriteString("```text\n")
+			for k := 0; k < 10; k++ {
+				fmt.Fprintf(&sb, "code line %d\n", k)
+			}
+			sb.WriteString("```\n\n")
+		}
+	}
+	return sb.String()
+}
+
+// isFenceDelimiterLine reports whether line is nothing but a fence
+// delimiter (3+ backticks, optionally followed by an info string) — used
+// to skip both original and chunker-synthesized delimiter lines when
+// comparing word content across chunk boundaries.
+func isFenceDelimiterLine(line string) bool {
+	trimmed := strings.TrimSpace(line)
+	return len(trimmed) >= 3 && strings.HasPrefix(trimmed, "```")
+}
+
+// wordsSkippingFenceLines returns the whitespace-separated words of text,
+// skipping any line that is purely a fence delimiter.
+func wordsSkippingFenceLines(text string) []string {
+	var words []string
+	for _, line := range strings.Split(text, "\n") {
+		if isFenceDelimiterLine(line) {
+			continue
+		}
+		words = append(words, strings.Fields(line)...)
+	}
+	return words
+}
+
+// countBacktickToggles counts lines that open or close a ``` fence. This
+// is an independent (deliberately simpler) check from the package's own
+// fenceState/scanFence machinery, used to assert chunks are individually
+// fence-balanced without relying on the code under test to grade itself.
+func countBacktickToggles(text string) int {
+	n := 0
+	for _, line := range strings.Split(text, "\n") {
+		if isFenceDelimiterLine(line) {
+			n++
+		}
+	}
+	return n
+}
+
+func tail(s string, n int) string {
+	r := []rune(s)
+	if len(r) <= n {
+		return s
+	}
+	return string(r[len(r)-n:])
+}
diff --git a/internal/bridge/markdown/telegram.go b/internal/bridge/markdown/telegram.go
new file mode 100644
index 0000000000..0f994cd22f
--- /dev/null
+++ b/internal/bridge/markdown/telegram.go
@@ -0,0 +1,500 @@
+// ToTelegramHTML converts GFM-flavored markdown (as an LLM emits it) into
+// Telegram's restricted HTML dialect (models.ParseModeHTML). Telegram's
+// parser supports only a small, fixed tag set — /, /,
+// /, //, , , 
,
+// 
, 
, — and +// requires escaping exactly three characters (&, <, >) everywhere else. +// See design.md "Telegram GFM -> HTML mapping" for the full construct +// table this file implements. +package markdown + +import ( + "regexp" + "strconv" + "strings" +) + +var ( + headingRe = regexp.MustCompile(`^#{1,6}\s+(.*)$`) + hrRe = regexp.MustCompile(`^(-{3,}|\*{3,}|_{3,})$`) + taskItemRe = regexp.MustCompile(`^(\s*)[-*+] \[([ xX])\] (.*)$`) + ulItemRe = regexp.MustCompile(`^(\s*)[-*+] (.*)$`) + inlineCodeRe = regexp.MustCompile("`([^`\n]+)`") +) + +// placeholder holds the two renderings of one extracted code construct. +// +// html is the real Telegram tag (.../
...), used when the
+// construct sits at the top level of the message. plain is the same
+// content escaped but UNTAGGED, used when the construct would otherwise
+// land inside another entity: the Bot API's "Formatting options" nesting
+// rules state that bold/italic/underline/strikethrough/spoiler entities
+// "can contain and can be part of any other entities, EXCEPT pre and
+// code", and that "all other entities can't contain each other". A
+//  nested in  (e.g. the very common "## Fix `foo.go`" heading),
+// in 
 (a table cell holding a code span) or in  is therefore
+// rejected by Telegram with "can't parse entities", costing the whole
+// chunk its formatting. Flattening to plain keeps the message valid.
+type placeholder struct {
+	html  string
+	plain string
+}
+
+// ToTelegramHTML converts s from GFM markdown to Telegram-safe HTML.
+//
+// Implementation shape (see design.md): code spans and fenced code
+// blocks are extracted into opaque placeholder tokens FIRST, so no later
+// inline-emphasis or link substitution can ever fire inside code
+// content. Line-oriented block transforms (headings, lists, blockquotes,
+// tables, horizontal rules) and inline transforms (bold/italic/strike/
+// links) run on what remains, escaping "&<>" in plain-text runs as they
+// go. Finally the placeholders are restored as escaped /
+// content — except where a placeholder ended up inside another entity,
+// in which case it is flattened to plain escaped text (see placeholder).
+//
+// Never panics; unbalanced markers (unclosed "**", "[label](" with no
+// closing paren) are left as literal escaped text rather than emitting a
+// broken tag. An unclosed ``` fence is the one exception called out by
+// the design: because we already commit to opening a 
 once we see
+// the opening delimiter, we auto-close it with whatever content follows,
+// rather than leaving a dangling, unparseable code fence marker.
+func ToTelegramHTML(s string) string {
+	if s == "" {
+		return ""
+	}
+	placeholders := map[string]placeholder{}
+	counter := 0
+	withoutFences := extractFences(s, &counter, placeholders)
+	withoutCode := extractInlineCode(withoutFences, &counter, placeholders)
+	converted := convertBlocks(withoutCode, placeholders)
+	return restorePlaceholders(converted, placeholders)
+}
+
+// newPlaceholder returns the next opaque placeholder token. The token
+// body is restricted to a NUL byte, the letters "md", and digits — none
+// of which are markdown metacharacters or HTML-escapable characters, so
+// the token can pass through every later transform untouched and is
+// restored verbatim in the final pass.
+func newPlaceholder(counter *int) string {
+	*counter++
+	return "\x00md" + strconv.Itoa(*counter) + "\x00"
+}
+
+// restorePlaceholders replaces every placeholder token still present with
+// its final (already-escaped) tagged HTML content. Tokens that were
+// flattened earlier by flattenPlaceholders are already gone by this point.
+func restorePlaceholders(s string, placeholders map[string]placeholder) string {
+	if len(placeholders) == 0 {
+		return s
+	}
+	for token, ph := range placeholders {
+		s = strings.ReplaceAll(s, token, ph.html)
+	}
+	return s
+}
+
+// flattenPlaceholders replaces every placeholder token in s with its
+// UNTAGGED escaped content. Callers apply it to text they are about to
+// wrap in an entity tag (, , , , 
, 
), because +// Telegram forbids code/pre entities nested inside any other entity — +// see the placeholder type for the exact rule and the failure mode. +func flattenPlaceholders(s string, placeholders map[string]placeholder) string { + if len(placeholders) == 0 || !strings.Contains(s, "\x00") { + return s + } + for token, ph := range placeholders { + s = strings.ReplaceAll(s, token, ph.plain) + } + return s +} + +// extractFences scans s line by line for ``` fences (reusing the same +// countLeadingBackticks helper markdown.go's chunker uses) and replaces +// each fenced block — delimiters, info string and all — with a single +// placeholder line. A fence with no matching close consumes the rest of +// the text as its content rather than being left unrecognized, so the +// eventual restoration always emits a well-formed, closed
.
+func extractFences(s string, counter *int, placeholders map[string]placeholder) string {
+	if !strings.Contains(s, "```") {
+		return s
+	}
+	lines := strings.Split(s, "\n")
+	out := make([]string, 0, len(lines))
+	i := 0
+	for i < len(lines) {
+		line := lines[i]
+		trimmed := strings.TrimLeft(line, " \t")
+		n := countLeadingBackticks(trimmed)
+		if n < 3 {
+			out = append(out, line)
+			i++
+			continue
+		}
+		lang := strings.TrimSpace(trimmed[n:])
+
+		closeIdx := -1
+		for j := i + 1; j < len(lines); j++ {
+			t := strings.TrimLeft(lines[j], " \t")
+			cn := countLeadingBackticks(t)
+			if cn >= n && strings.TrimSpace(t[cn:]) == "" {
+				closeIdx = j
+				break
+			}
+		}
+
+		var content string
+		var next int
+		if closeIdx >= 0 {
+			content = strings.Join(lines[i+1:closeIdx], "\n")
+			next = closeIdx + 1
+		} else {
+			// Unclosed fence: treat everything to the end of the text
+			// as code content so we still emit a well-formed 
.
+			content = strings.Join(lines[i+1:], "\n")
+			next = len(lines)
+		}
+
+		token := newPlaceholder(counter)
+		placeholders[token] = placeholder{
+			html:  renderFencedCode(lang, content),
+			plain: escapeHTML(content),
+		}
+		out = append(out, token)
+		i = next
+	}
+	return strings.Join(out, "\n")
+}
+
+// renderFencedCode renders one fenced block's final HTML. Content is
+// escaped for "&<>" only — never reparsed for markdown — and whitespace/
+// newlines are preserved exactly.
+func renderFencedCode(lang, content string) string {
+	if lang == "" {
+		return "
" + escapeHTML(content) + "
" + } + return `
` + escapeHTML(content) + `
` +} + +// extractInlineCode replaces every `code` span (single-backtick, +// single-line) with a placeholder token holding its escaped +// content. +func extractInlineCode(s string, counter *int, placeholders map[string]placeholder) string { + if !strings.Contains(s, "`") { + return s + } + return inlineCodeRe.ReplaceAllStringFunc(s, func(m string) string { + content := m[1 : len(m)-1] + token := newPlaceholder(counter) + placeholders[token] = placeholder{ + html: "" + escapeHTML(content) + "", + plain: escapeHTML(content), + } + return token + }) +} + +// convertBlocks applies the line-oriented GFM->HTML mapping (tables, +// blockquotes, headings, horizontal rules, task/unordered list items) +// and, for every other line, the inline mapping via processInline. +func convertBlocks(s string, ph map[string]placeholder) string { + lines := strings.Split(s, "\n") + out := make([]string, 0, len(lines)) + i := 0 + for i < len(lines) { + line := lines[i] + trimmed := strings.TrimSpace(line) + + if isTableLine(line) { + j := i + for j < len(lines) && isTableLine(lines[j]) { + j++ + } + if j-i >= 2 { + run := strings.Join(lines[i:j], "\n") + out = append(out, "
"+flattenPlaceholders(escapeHTML(run), ph)+"
") + i = j + continue + } + // A lone `|...|` line isn't a table run (design requires + // 2+ consecutive lines) — fall through to normal handling. + } + + if strings.HasPrefix(trimmed, ">") { + j := i + var quoteLines []string + for j < len(lines) { + t := strings.TrimSpace(lines[j]) + if !strings.HasPrefix(t, ">") { + break + } + inner := strings.TrimPrefix(t, ">") + inner = strings.TrimPrefix(inner, " ") + quoteLines = append(quoteLines, processInline(inner, ph)) + j++ + } + body := flattenPlaceholders(strings.Join(quoteLines, "\n"), ph) + out = append(out, "
"+body+"
") + i = j + continue + } + + if m := headingRe.FindStringSubmatch(line); m != nil { + out = append(out, ""+flattenPlaceholders(processInline(m[1], ph), ph)+"") + i++ + continue + } + + if hrRe.MatchString(trimmed) { + out = append(out, strings.Repeat("\u2014", 10)) + i++ + continue + } + + if m := taskItemRe.FindStringSubmatch(line); m != nil { + box := "\u2610" + if strings.EqualFold(m[2], "x") { + box = "\u2611" + } + out = append(out, m[1]+box+" "+processInline(m[3], ph)) + i++ + continue + } + + if m := ulItemRe.FindStringSubmatch(line); m != nil { + out = append(out, m[1]+"\u2022 "+processInline(m[2], ph)) + i++ + continue + } + + out = append(out, processInline(line, ph)) + i++ + } + return strings.Join(out, "\n") +} + +// isTableLine reports whether line, trimmed, both starts and ends with +// "|" — the shape shared by a GFM table's header, separator, and data +// rows. +func isTableLine(line string) bool { + t := strings.TrimSpace(line) + return len(t) >= 2 && strings.HasPrefix(t, "|") && strings.HasSuffix(t, "|") +} + +// processInline applies the inline GFM->HTML mapping (bold, italic, +// strikethrough, links, images) to s and escapes "&<>" in every plain- +// text run it does not otherwise transform. Delimiters with no matching +// close are emitted as literal (escaped) text rather than an unclosed +// tag. Bold/italic/strikethrough content is reprocessed recursively so +// nesting (e.g. italic inside bold) round-trips correctly; code +// placeholders inside any emitted tag are flattened to plain text, +// because Telegram rejects code/pre nested in another entity. +func processInline(s string, ph map[string]placeholder) string { + var b strings.Builder + i := 0 + n := len(s) + for i < n { + c := s[i] + switch { + case c == '!' && i+1 < n && s[i+1] == '[': + if label, url, newPos, ok := matchLink(s, i+1); ok { + if label == "" { + b.WriteString(flattenPlaceholders(escapeHTML(url), ph)) + } else { + writeLink(&b, label, url, ph) + } + i = newPos + continue + } + b.WriteByte('!') + i++ + + case c == '[': + if label, url, newPos, ok := matchLink(s, i); ok { + writeLink(&b, label, url, ph) + i = newPos + continue + } + b.WriteByte('[') + i++ + + case i+1 < n && (s[i:i+2] == "**" || s[i:i+2] == "__"): + marker := s[i : i+2] + if inner, newPos, ok := matchDelim(s, i, marker); ok { + b.WriteString("") + b.WriteString(flattenPlaceholders(processInline(inner, ph), ph)) + b.WriteString("") + i = newPos + continue + } + b.WriteString(marker) + i += 2 + + case i+1 < n && s[i:i+2] == "~~": + if inner, newPos, ok := matchDelim(s, i, "~~"); ok { + b.WriteString("") + b.WriteString(flattenPlaceholders(processInline(inner, ph), ph)) + b.WriteString("") + i = newPos + continue + } + b.WriteString("~~") + i += 2 + + case c == '*': + if inner, newPos, ok := matchDelim(s, i, "*"); ok { + b.WriteString("") + b.WriteString(flattenPlaceholders(processInline(inner, ph), ph)) + b.WriteString("") + i = newPos + continue + } + b.WriteByte('*') + i++ + + case c == '_': + // Underscore emphasis requires a non-word boundary on both + // sides (CommonMark's own rule) so "snake_case_name" is + // never mistaken for italic markup. + leftOK := i == 0 || !isWordByte(s[i-1]) + if leftOK { + if inner, newPos, ok := matchDelim(s, i, "_"); ok { + rightOK := newPos >= n || !isWordByte(s[newPos]) + if rightOK { + b.WriteString("") + b.WriteString(flattenPlaceholders(processInline(inner, ph), ph)) + b.WriteString("") + i = newPos + continue + } + } + } + b.WriteByte('_') + i++ + + default: + switch c { + case '&': + b.WriteString("&") + case '<': + b.WriteString("<") + case '>': + b.WriteString(">") + default: + b.WriteByte(c) + } + i++ + } + } + return b.String() +} + +// writeLink emits one
label. The URL goes through +// escapeAttr (not escapeHTML) because it lands inside a double-quoted +// attribute: a URL containing `"` would otherwise close the attribute +// early and inject arbitrary attributes into the tag, which Telegram +// rejects as an unsupported tag shape. Label and URL are additionally +// flattened so a code span inside either never becomes a nested +// in the entity. +func writeLink(b *strings.Builder, label, url string, ph map[string]placeholder) { + b.WriteString(``) + b.WriteString(flattenPlaceholders(escapeHTML(label), ph)) + b.WriteString(``) +} + +// matchDelim looks for marker again, starting right after the opening +// occurrence at pos, and returns the text between them plus the index +// just past the closing occurrence. ok is false when no closing +// occurrence exists (caller then treats the opening marker as literal +// text rather than opening a tag it cannot close). +func matchDelim(s string, pos int, marker string) (inner string, newPos int, ok bool) { + start := pos + len(marker) + if start > len(s) { + return "", 0, false + } + idx := strings.Index(s[start:], marker) + if idx < 0 { + return "", 0, false + } + return s[start : start+idx], start + idx + len(marker), true +} + +// matchLink parses a `[label](url)` construct starting at pos (where +// s[pos] must be '['). ok is false for `[label](` with no closing paren +// (or no closing bracket at all), so the caller falls back to literal +// text instead of an unclosed tag. +func matchLink(s string, pos int) (label, url string, newPos int, ok bool) { + if pos >= len(s) || s[pos] != '[' { + return "", "", 0, false + } + closeBracket := strings.IndexByte(s[pos+1:], ']') + if closeBracket < 0 { + return "", "", 0, false + } + closeBracket += pos + 1 + if closeBracket+1 >= len(s) || s[closeBracket+1] != '(' { + return "", "", 0, false + } + closeParen := strings.IndexByte(s[closeBracket+2:], ')') + if closeParen < 0 { + return "", "", 0, false + } + closeParen += closeBracket + 2 + return s[pos+1 : closeBracket], s[closeBracket+2 : closeParen], closeParen + 1, true +} + +// isWordByte reports whether b is an ASCII letter, digit, or underscore +// — CommonMark's definition of a "word" character used for the +// underscore-emphasis boundary check. +func isWordByte(b byte) bool { + return b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') +} + +// escapeHTML escapes the three characters Telegram HTML requires +// escaped in plain-text runs: "&" -> "&", "<" -> "<", +// ">" -> ">". Iterates by byte (not rune) — safe because none of the +// three ASCII targets ever appears as a continuation byte of a +// multi-byte UTF-8 sequence, so other bytes pass through unmodified and +// unmodified sequences stay valid UTF-8. +func escapeHTML(s string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + switch s[i] { + case '&': + b.WriteString("&") + case '<': + b.WriteString("<") + case '>': + b.WriteString(">") + default: + b.WriteByte(s[i]) + } + } + return b.String() +} + +// escapeAttr escapes a value destined for a double-quoted HTML attribute: +// everything escapeHTML handles, plus '"' -> """ so the value can +// never terminate the attribute early. " is one of the four named +// entities the Bot API documents as supported. +func escapeAttr(s string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + switch s[i] { + case '&': + b.WriteString("&") + case '<': + b.WriteString("<") + case '>': + b.WriteString(">") + case '"': + b.WriteString(""") + default: + b.WriteByte(s[i]) + } + } + return b.String() +} diff --git a/internal/bridge/markdown/telegram_nesting_test.go b/internal/bridge/markdown/telegram_nesting_test.go new file mode 100644 index 0000000000..620c015f29 --- /dev/null +++ b/internal/bridge/markdown/telegram_nesting_test.go @@ -0,0 +1,181 @@ +package markdown + +import ( + "strings" + "testing" +) + +// Telegram's Bot API nesting rules (see "Formatting options"): +// +// bold, italic, underline, strikethrough, and spoiler entities can +// contain and can be part of any other entities, except pre and code. +// [...] All other entities can't contain each other. +// +// So a or
 inside ANY other tag is rejected with "can't parse
+// entities", which costs the whole chunk its formatting via the plain-text
+// retry. These tests pin that no such nesting is ever emitted.
+func TestToTelegramHTMLNeverNestsCodeInsideAnotherEntity(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+	}{
+		{"heading with inline code", "## Fix `foo.go` now"},
+		{"heading with code and bold", "### Use `--flag` for **speed**"},
+		{"bold wrapping inline code", "**run `make test` first**"},
+		{"italic wrapping inline code", "*see `config.yaml`*"},
+		{"underscore italic wrapping code", "_see `config.yaml` here_"},
+		{"strikethrough wrapping code", "~~old `api.Call()` removed~~"},
+		{"link label containing code", "[`pkg.Func`](https://example.com)"},
+		{"blockquote with inline code", "> note: run `go vet`\n> then build"},
+		{"table cell with inline code", "| col | val |\n| --- | --- |\n| `code` | x |"},
+		{"nested bold inside italic with code", "*outer **inner `c`** tail*"},
+		{"list item with code (top level, allowed)", "- run `make test`"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := ToTelegramHTML(tc.in)
+			assertNoNestedCodeOrPre(t, got)
+		})
+	}
+}
+
+// assertNoNestedCodeOrPre walks html and fails if a  or 
 open
+// tag appears while any other tag is still open, or if any tag is open
+// inside a /
. The one exception is the documented
+// `
` language-specifier form.
+func assertNoNestedCodeOrPre(t *testing.T, html string) {
+	t.Helper()
+	var stack []string
+	i := 0
+	for i < len(html) {
+		lt := strings.IndexByte(html[i:], '<')
+		if lt < 0 {
+			return
+		}
+		i += lt
+		gt := strings.IndexByte(html[i:], '>')
+		if gt < 0 {
+			t.Fatalf("unterminated tag at byte %d in %q", i, html)
+		}
+		raw := html[i+1 : i+gt]
+		i += gt + 1
+
+		closing := strings.HasPrefix(raw, "/")
+		name := strings.TrimPrefix(raw, "/")
+		if sp := strings.IndexByte(name, ' '); sp >= 0 {
+			name = name[:sp]
+		}
+
+		if closing {
+			if len(stack) > 0 && stack[len(stack)-1] == name {
+				stack = stack[:len(stack)-1]
+			}
+			continue
+		}
+
+		if name == "code" || name == "pre" {
+			// `
` is the one documented,
+			// supported nesting.
+			preCode := name == "code" && len(stack) == 1 && stack[0] == "pre" &&
+				strings.Contains(raw, `class="language-`)
+			if len(stack) > 0 && !preCode {
+				t.Fatalf("<%s> nested inside %v — Telegram rejects code/pre inside another entity: %q",
+					name, stack, html)
+			}
+		} else if len(stack) > 0 && (stack[len(stack)-1] == "code" || stack[len(stack)-1] == "pre") {
+			t.Fatalf("<%s> nested inside <%s> — code/pre may not contain other entities: %q",
+				name, stack[len(stack)-1], html)
+		}
+		stack = append(stack, name)
+	}
+}
+
+// TestToTelegramHTMLFlattensCodeButKeepsContent asserts the flattening
+// degrades markup only, never the code text itself.
+func TestToTelegramHTMLFlattensCodeButKeepsContent(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{
+			name: "heading",
+			in:   "## Fix `foo.go`",
+			want: "Fix foo.go",
+		},
+		{
+			name: "bold",
+			in:   "**run `make test`**",
+			want: "run make test",
+		},
+		{
+			name: "blockquote",
+			in:   "> run `go vet`",
+			want: "
run go vet
", + }, + { + name: "code content is still escaped when flattened", + in: "## compare `a < b && c > d`", + want: "compare a < b && c > d", + }, + { + name: "top-level code keeps its tag", + in: "plain `code` here", + want: "plain code here", + }, + { + name: "top-level fence keeps pre+code", + in: "```go\nx := 1\n```", + want: "
x := 1
", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ToTelegramHTML(tc.in); got != tc.want { + t.Errorf("ToTelegramHTML(%q)\n got: %q\nwant: %q", tc.in, got, tc.want) + } + }) + } +} + +// TestToTelegramHTMLEscapesHrefAttribute covers the attribute-injection +// bug: escapeHTML does not escape '"', so a URL containing one closed the +// href early and injected an unsupported attribute into the
tag, +// which Telegram rejects. +func TestToTelegramHTMLEscapesHrefAttribute(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "quote in url cannot break out of the attribute", + in: `[label](https://x.com" onclick="alert(1))`, + want: `label)`, + }, + { + name: "ampersand in query string stays escaped once", + in: `[a](https://x?a=1&b=2)`, + want: `a`, + }, + { + name: "angle brackets in url are escaped", + in: `[a](https://x?q=