format.ts
4633 bytes
1import type { Part } from "@opencode-ai/sdk/client";
2
3const TELEGRAM_MAX = 4096;
4
5// eslint-disable-next-line no-useless-escape
6const MDVV2_SPECIAL = /[_*\[\]()~`>#+\-=|{}.!\\]/g;
7
8export function escapeMarkdownV2(text: string): string {
9 return text.replace(MDVV2_SPECIAL, "\\$&");
10}
11
12/**
13 * Escape text for MarkdownV2 but preserve code blocks (``` delimited).
14 * Inside code blocks only ` and \ need escaping.
15 */
16function escapeWithCodeBlocks(text: string): string {
17 const parts: string[] = [];
18 let cursor = 0;
19
20 // Match fenced code blocks: ```lang?\n...\n```
21 const codeBlockRe = /```[\s\S]*?```/g;
22 let match: RegExpExecArray | null;
23
24 while ((match = codeBlockRe.exec(text)) !== null) {
25 // Escape text before this code block
26 if (match.index > cursor) {
27 parts.push(escapeMarkdownV2(text.slice(cursor, match.index)));
28 }
29 // Code blocks are passed through as-is — Telegram handles them natively
30 parts.push(match[0]);
31 cursor = match.index + match[0].length;
32 }
33
34 // Remaining text after last code block
35 if (cursor < text.length) {
36 parts.push(escapeMarkdownV2(text.slice(cursor)));
37 }
38
39 return parts.join("");
40}
41
42/** Escape text for use inside a MarkdownV2 code block (only ` and \ need escaping). */
43function escapeCodeContent(text: string): string {
44 return text.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
45}
46
47function formatToolPart(part: Extract<Part, { type: "tool" }>): string {
48 const { state } = part;
49 const input = state.input;
50 const target =
51 (input["path"] as string | undefined) ??
52 (input["filePath"] as string | undefined) ??
53 (input["file"] as string | undefined) ??
54 (input["command"] as string | undefined) ??
55 "";
56 const suffix = target ? ` (${target})` : "";
57 const header = "Tool: " + part.tool + suffix;
58
59 if (state.status === "completed" && state.output) {
60 let output = state.output;
61 if (output.length > 3000)
62 output = output.slice(0, 3000) + "\n... (truncated)";
63 return "```\n" + escapeCodeContent(header + "\n" + output) + "\n```";
64 }
65 if (state.status === "error") {
66 return (
67 "```\n" + escapeCodeContent(header + "\nError: " + state.error) + "\n```"
68 );
69 }
70 return "```\n" + escapeCodeContent(header) + "\n```";
71}
72
73/** Format only text parts (no tool summaries) */
74export function formatTextParts(parts: Part[]): string {
75 const segments: string[] = [];
76 for (const part of parts) {
77 if (part.type === "text") segments.push(escapeWithCodeBlocks(part.text));
78 }
79 return segments.join("\n");
80}
81
82/** Extract formatted text from OpenCode Part[] response */
83export function formatParts(parts: Part[]): string {
84 const segments: string[] = [];
85
86 for (const part of parts) {
87 switch (part.type) {
88 case "text":
89 segments.push(escapeWithCodeBlocks(part.text));
90 break;
91 case "tool":
92 segments.push(formatToolPart(part));
93 break;
94 // Other part types (step-start, step-finish, etc.) are ignored
95 }
96 }
97
98 return segments.join("\n");
99}
100
101/**
102 * Split a formatted message into chunks that fit within Telegram's 4096 char limit.
103 * Respects code block boundaries — won't split inside a fenced block.
104 */
105export function splitMessage(text: string): string[] {
106 if (text.length <= TELEGRAM_MAX) return [text];
107
108 const chunks: string[] = [];
109 let remaining = text;
110
111 while (remaining.length > 0) {
112 if (remaining.length <= TELEGRAM_MAX) {
113 chunks.push(remaining);
114 break;
115 }
116
117 let splitAt = TELEGRAM_MAX;
118
119 // Check if splitting here would land inside a code block
120 const prefix = remaining.slice(0, splitAt);
121 const backtickCount = countUnescapedTripleBackticks(prefix);
122
123 if (backtickCount % 2 !== 0) {
124 // We're inside a code block — find the closing ``` before the limit
125 const lastClose = prefix.lastIndexOf("```");
126 if (lastClose > 0) {
127 splitAt = lastClose;
128 }
129 // If no closing found, we have an enormous code block — just split at limit
130 }
131
132 // Try to split at a newline for cleaner breaks
133 const newline = remaining.lastIndexOf("\n", splitAt);
134 if (newline > splitAt * 0.5) {
135 splitAt = newline + 1;
136 }
137
138 chunks.push(remaining.slice(0, splitAt));
139 remaining = remaining.slice(splitAt);
140 }
141
142 return chunks;
143}
144
145function countUnescapedTripleBackticks(text: string): number {
146 let count = 0;
147 let i = 0;
148 while (i < text.length - 2) {
149 if (text[i] === "`" && text[i + 1] === "`" && text[i + 2] === "`") {
150 if (i === 0 || text[i - 1] !== "\\") {
151 count++;
152 }
153 i += 3;
154 } else {
155 i++;
156 }
157 }
158 return count;
159}