Parent directory

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}