@grinev/opencode-telegram-bot 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.env.example +13 -0
  2. package/README.md +6 -0
  3. package/dist/app/start-bot-app.js +6 -1
  4. package/dist/bot/assistant-run-state.js +60 -0
  5. package/dist/bot/commands/abort.js +2 -0
  6. package/dist/bot/commands/commands.js +10 -0
  7. package/dist/bot/commands/definitions.js +1 -0
  8. package/dist/bot/commands/open.js +314 -0
  9. package/dist/bot/commands/projects.js +5 -38
  10. package/dist/bot/commands/start.js +2 -0
  11. package/dist/bot/handlers/inline-menu.js +9 -1
  12. package/dist/bot/handlers/prompt.js +11 -0
  13. package/dist/bot/index.js +162 -98
  14. package/dist/bot/streaming/response-streamer.js +26 -17
  15. package/dist/bot/utils/assistant-rendering.js +117 -0
  16. package/dist/bot/utils/assistant-run-footer.js +9 -0
  17. package/dist/bot/utils/browser-roots.js +140 -0
  18. package/dist/bot/utils/file-tree.js +92 -0
  19. package/dist/bot/utils/finalize-assistant-response.js +18 -24
  20. package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
  21. package/dist/bot/utils/switch-project.js +48 -0
  22. package/dist/bot/utils/telegram-text.js +95 -1
  23. package/dist/cli.js +4 -0
  24. package/dist/config.js +3 -0
  25. package/dist/i18n/de.js +15 -0
  26. package/dist/i18n/en.js +15 -0
  27. package/dist/i18n/es.js +15 -0
  28. package/dist/i18n/fr.js +15 -0
  29. package/dist/i18n/ru.js +15 -0
  30. package/dist/i18n/zh.js +15 -0
  31. package/dist/index.js +2 -0
  32. package/dist/project/manager.js +2 -1
  33. package/dist/summary/aggregator.js +17 -1
  34. package/dist/summary/formatter.js +2 -88
  35. package/dist/telegram/render/block-fallback.js +28 -0
  36. package/dist/telegram/render/block-parser.js +295 -0
  37. package/dist/telegram/render/block-renderer.js +457 -0
  38. package/dist/telegram/render/chunker.js +281 -0
  39. package/dist/telegram/render/inline-renderer.js +128 -0
  40. package/dist/telegram/render/markdown-normalizer.js +94 -0
  41. package/dist/telegram/render/pipeline.js +9 -0
  42. package/dist/telegram/render/types.js +1 -0
  43. package/dist/telegram/render/validator.js +160 -0
  44. package/dist/utils/logger.js +200 -73
  45. package/package.json +6 -2
package/dist/i18n/es.js CHANGED
@@ -345,4 +345,19 @@ export const es = {
345
345
  "stt.not_configured": "🎤 El reconocimiento de voz no está configurado.\n\nConfigura STT_API_URL y STT_API_KEY en .env para habilitarlo.",
346
346
  "stt.error": "🔴 No se pudo reconocer el audio: {error}",
347
347
  "stt.empty_result": "🎤 No se detectó voz en el mensaje de audio.",
348
+ "cmd.description.open": "Añadir proyecto navegando directorios",
349
+ "open.back": "⬆️ Subir",
350
+ "open.roots": "📋 Volver a raíces",
351
+ "open.prev_page": "⬅️ Anterior",
352
+ "open.next_page": "Siguiente ➡️",
353
+ "open.select_current": "✅ Seleccionar esta carpeta",
354
+ "open.select_root": "📂 Selecciona un directorio raíz para explorar:",
355
+ "open.access_denied": "⛔ Acceso denegado: la ruta está fuera de los directorios permitidos",
356
+ "open.scan_error": "🔴 No se puede explorar el directorio: {error}",
357
+ "open.open_error": "🔴 No se pudo abrir el explorador de directorios.",
358
+ "open.selected": "✅ Proyecto añadido: {project}\n\n📋 Usa /sessions o /new para empezar a trabajar.",
359
+ "open.select_error": "🔴 No se pudo añadir el proyecto.",
360
+ "open.no_subfolders": "📭 Sin subcarpetas",
361
+ "open.subfolder_count": "{count} subcarpeta",
362
+ "open.subfolders_count": "{count} subcarpetas",
348
363
  };
package/dist/i18n/fr.js CHANGED
@@ -345,4 +345,19 @@ export const fr = {
345
345
  "stt.not_configured": "🎤 La reconnaissance vocale n'est pas configurée.\n\nDéfinissez STT_API_URL et STT_API_KEY dans .env pour l'activer.",
346
346
  "stt.error": "🔴 Impossible de reconnaître l'audio : {error}",
347
347
  "stt.empty_result": "🎤 Aucune parole détectée dans le message audio.",
348
+ "cmd.description.open": "Ajouter un projet en parcourant les dossiers",
349
+ "open.back": "⬆️ Remonter",
350
+ "open.roots": "📋 Retour aux racines",
351
+ "open.prev_page": "⬅️ Précédent",
352
+ "open.next_page": "Suivant ➡️",
353
+ "open.select_current": "✅ Sélectionner ce dossier",
354
+ "open.select_root": "📂 Sélectionnez un répertoire racine à parcourir :",
355
+ "open.access_denied": "⛔ Accès refusé : le chemin est en dehors des répertoires autorisés",
356
+ "open.scan_error": "🔴 Impossible de parcourir le répertoire : {error}",
357
+ "open.open_error": "🔴 Impossible d'ouvrir l'explorateur de répertoires.",
358
+ "open.selected": "✅ Projet ajouté : {project}\n\n📋 Utilisez /sessions ou /new pour commencer.",
359
+ "open.select_error": "🔴 Impossible d'ajouter le projet.",
360
+ "open.no_subfolders": "📭 Aucun sous-dossier",
361
+ "open.subfolder_count": "{count} sous-dossier",
362
+ "open.subfolders_count": "{count} sous-dossiers",
348
363
  };
package/dist/i18n/ru.js CHANGED
@@ -345,4 +345,19 @@ export const ru = {
345
345
  "stt.not_configured": "🎤 Распознавание голоса не настроено.\n\nУстановите STT_API_URL и STT_API_KEY в .env для включения.",
346
346
  "stt.error": "🔴 Не удалось распознать аудио: {error}",
347
347
  "stt.empty_result": "🎤 В аудиосообщении не обнаружена речь.",
348
+ "cmd.description.open": "Добавить проект через обзор папок",
349
+ "open.back": "⬆️ Наверх",
350
+ "open.roots": "📋 К списку корней",
351
+ "open.prev_page": "⬅️ Назад",
352
+ "open.next_page": "Далее ➡️",
353
+ "open.select_current": "✅ Выбрать эту папку",
354
+ "open.select_root": "📂 Выберите корневой каталог для просмотра:",
355
+ "open.access_denied": "⛔ Доступ запрещён: путь за пределами разрешённых каталогов",
356
+ "open.scan_error": "🔴 Не удалось открыть каталог: {error}",
357
+ "open.open_error": "🔴 Не удалось открыть обозреватель каталогов.",
358
+ "open.selected": "✅ Проект добавлен: {project}\n\n📋 Используйте /sessions или /new для начала работы.",
359
+ "open.select_error": "🔴 Не удалось добавить проект.",
360
+ "open.no_subfolders": "📭 Нет подпапок",
361
+ "open.subfolder_count": "{count} подпапка",
362
+ "open.subfolders_count": "{count} подпапок",
348
363
  };
package/dist/i18n/zh.js CHANGED
@@ -345,4 +345,19 @@ export const zh = {
345
345
  "stt.not_configured": "🎤 语音识别尚未配置。\n\n在 .env 中设置 STT_API_URL 和 STT_API_KEY 以启用。",
346
346
  "stt.error": "🔴 识别音频失败:{error}",
347
347
  "stt.empty_result": "🎤 音频消息中未检测到语音。",
348
+ "cmd.description.open": "通过浏览目录添加项目",
349
+ "open.back": "⬆️ 上级",
350
+ "open.roots": "📋 返回根目录",
351
+ "open.prev_page": "⬅️ 上一页",
352
+ "open.next_page": "下一页 ➡️",
353
+ "open.select_current": "✅ 选择此文件夹",
354
+ "open.select_root": "📂 选择要浏览的根目录:",
355
+ "open.access_denied": "⛔ 访问被拒绝:路径超出允许的目录范围",
356
+ "open.scan_error": "🔴 无法浏览目录:{error}",
357
+ "open.open_error": "🔴 无法打开目录浏览器。",
358
+ "open.selected": "✅ 项目已添加:{project}\n\n📋 使用 /sessions 或 /new 开始工作。",
359
+ "open.select_error": "🔴 添加项目失败。",
360
+ "open.no_subfolders": "📭 无子文件夹",
361
+ "open.subfolder_count": "{count} 个子文件夹",
362
+ "open.subfolders_count": "{count} 个子文件夹",
348
363
  };
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ async function main() {
12
12
  return;
13
13
  }
14
14
  setRuntimeMode(modeResult.mode);
15
+ const { initializeLogger } = await import("./utils/logger.js");
16
+ await initializeLogger();
15
17
  const { startBotApp } = await import("./app/start-bot-app.js");
16
18
  await startBotApp();
17
19
  }
@@ -80,7 +80,8 @@ export async function getProjectById(id) {
80
80
  }
81
81
  export async function getProjectByWorktree(worktree) {
82
82
  const projects = await getProjects();
83
- const project = projects.find((p) => p.worktree === worktree);
83
+ const key = worktreeKey(worktree);
84
+ const project = projects.find((p) => worktreeKey(p.worktree) === key);
84
85
  if (!project) {
85
86
  throw new Error(`Project with worktree ${worktree} not found`);
86
87
  }
@@ -43,6 +43,7 @@ class SummaryAggregator {
43
43
  onSessionCompactedCallback = null;
44
44
  onSessionErrorCallback = null;
45
45
  onSessionRetryCallback = null;
46
+ onSessionIdleCallback = null;
46
47
  onPermissionCallback = null;
47
48
  onSessionDiffCallback = null;
48
49
  onFileChangeCallback = null;
@@ -105,6 +106,9 @@ class SummaryAggregator {
105
106
  setOnSessionRetry(callback) {
106
107
  this.onSessionRetryCallback = callback;
107
108
  }
109
+ setOnSessionIdle(callback) {
110
+ this.onSessionIdleCallback = callback;
111
+ }
108
112
  setOnPermission(callback) {
109
113
  this.onPermissionCallback = callback;
110
114
  }
@@ -643,7 +647,13 @@ class SummaryAggregator {
643
647
  this.onCostCallback(assistantInfo.cost);
644
648
  }
645
649
  if (this.onCompleteCallback && finalText.length > 0) {
646
- this.onCompleteCallback(this.currentSessionId, messageID, finalText);
650
+ this.onCompleteCallback(this.currentSessionId, messageID, finalText, {
651
+ agent: assistantMessage.agent,
652
+ providerID: assistantMessage.providerID,
653
+ modelID: assistantMessage.modelID,
654
+ createdAt: time?.created,
655
+ completedAt: time?.completed,
656
+ });
647
657
  }
648
658
  this.textMessageStates.delete(messageID);
649
659
  this.messages.delete(messageID);
@@ -1038,6 +1048,12 @@ class SummaryAggregator {
1038
1048
  logger.info(`[Aggregator] Session became idle: ${sessionID}`);
1039
1049
  // Stop typing indicator when session goes idle
1040
1050
  this.stopTypingIndicator();
1051
+ if (this.onSessionIdleCallback) {
1052
+ const callback = this.onSessionIdleCallback;
1053
+ setImmediate(() => {
1054
+ callback(sessionID);
1055
+ });
1056
+ }
1041
1057
  }
1042
1058
  handleSessionCompacted(event) {
1043
1059
  const properties = event.properties;
@@ -4,6 +4,7 @@ import { config } from "../config.js";
4
4
  import { logger } from "../utils/logger.js";
5
5
  import { t } from "../i18n/index.js";
6
6
  import { getCurrentProject } from "../settings/manager.js";
7
+ import { normalizeMarkdownForTelegramRendering } from "../telegram/render/markdown-normalizer.js";
7
8
  const TELEGRAM_MESSAGE_LIMIT = 4096;
8
9
  const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
9
10
  function truncateWithEllipsis(text, maxLength) {
@@ -59,87 +60,6 @@ function splitText(text, maxLength, options) {
59
60
  }
60
61
  return parts;
61
62
  }
62
- function isCodeFenceLine(line) {
63
- return line.trimStart().startsWith("```");
64
- }
65
- function isHorizontalRuleLine(line) {
66
- const normalized = line.trim();
67
- if (!normalized) {
68
- return false;
69
- }
70
- return /^([-*_])(?:\s*\1){2,}$/.test(normalized);
71
- }
72
- function isHeadingLine(line) {
73
- return /^\s{0,3}#{1,6}\s+\S/.test(line);
74
- }
75
- function normalizeHeadingLine(line) {
76
- const match = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/);
77
- if (!match) {
78
- return line;
79
- }
80
- return `**${match[1]}**`;
81
- }
82
- function normalizeChecklistLine(line) {
83
- const match = line.match(/^(\s*)(?:[-+*]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/);
84
- if (!match) {
85
- return null;
86
- }
87
- const marker = match[2].toLowerCase() === "x" ? "✅" : "🔲";
88
- return `${match[1]}${marker} ${match[3]}`;
89
- }
90
- function preprocessMarkdownForTelegram(text) {
91
- const lines = text.split("\n");
92
- const output = [];
93
- let inCodeFence = false;
94
- let inQuote = false;
95
- for (let index = 0; index < lines.length; index++) {
96
- const line = lines[index];
97
- if (isCodeFenceLine(line)) {
98
- inCodeFence = !inCodeFence;
99
- inQuote = false;
100
- output.push(line);
101
- continue;
102
- }
103
- if (inCodeFence) {
104
- output.push(line);
105
- continue;
106
- }
107
- if (!line.trim()) {
108
- inQuote = false;
109
- output.push(line);
110
- continue;
111
- }
112
- if (isHeadingLine(line)) {
113
- output.push(normalizeHeadingLine(line));
114
- inQuote = false;
115
- continue;
116
- }
117
- if (isHorizontalRuleLine(line)) {
118
- output.push("──────────");
119
- inQuote = false;
120
- continue;
121
- }
122
- const trimmedLeft = line.trimStart();
123
- if (trimmedLeft.startsWith(">")) {
124
- inQuote = true;
125
- const quoteContent = trimmedLeft.replace(/^>\s?/, "");
126
- const normalizedChecklistInQuote = normalizeChecklistLine(quoteContent);
127
- output.push(normalizedChecklistInQuote ? `> ${normalizedChecklistInQuote.trimStart()}` : trimmedLeft);
128
- continue;
129
- }
130
- const normalizedChecklist = normalizeChecklistLine(line);
131
- if (normalizedChecklist) {
132
- output.push(inQuote ? `> ${normalizedChecklist.trimStart()}` : normalizedChecklist);
133
- continue;
134
- }
135
- if (inQuote) {
136
- output.push(`> ${trimmedLeft}`);
137
- continue;
138
- }
139
- output.push(line);
140
- }
141
- return output.join("\n");
142
- }
143
63
  export function normalizePathForDisplay(filePath) {
144
64
  const normalizedPath = filePath.replace(/\\/g, "/");
145
65
  const project = getCurrentProject();
@@ -164,18 +84,12 @@ export function normalizePathForDisplay(filePath) {
164
84
  export function formatSummary(text) {
165
85
  return formatSummaryWithMode(text, config.bot.messageFormatMode);
166
86
  }
167
- export function getAssistantParseMode() {
168
- if (config.bot.messageFormatMode === "markdown") {
169
- return "MarkdownV2";
170
- }
171
- return undefined;
172
- }
173
87
  export function escapePlainTextForTelegramMarkdownV2(text) {
174
88
  return text.replace(MARKDOWN_V2_RESERVED_CHARS, "\\$1");
175
89
  }
176
90
  function formatMarkdownForTelegram(text) {
177
91
  try {
178
- const preprocessed = preprocessMarkdownForTelegram(text);
92
+ const preprocessed = normalizeMarkdownForTelegramRendering(text);
179
93
  return escapeMarkdownV2PipesOutsideCode(convert(preprocessed, "keep"));
180
94
  }
181
95
  catch (error) {
@@ -0,0 +1,28 @@
1
+ import { logger } from "../../utils/logger.js";
2
+ import * as blockRenderer from "./block-renderer.js";
3
+ const BLOCK_RENDER_ORDER = ["full", "simplified", "line-by-line", "plain"];
4
+ export function renderTelegramBlockWithFallback(block) {
5
+ let lastError;
6
+ const failures = [];
7
+ for (const mode of BLOCK_RENDER_ORDER) {
8
+ try {
9
+ const renderedBlock = blockRenderer.renderTelegramBlock(block, mode);
10
+ if (failures.length > 0) {
11
+ logger.debug("[TelegramRender] Block fallback applied", {
12
+ blockType: block.type,
13
+ selectedMode: renderedBlock.mode,
14
+ failures,
15
+ });
16
+ }
17
+ return renderedBlock;
18
+ }
19
+ catch (error) {
20
+ lastError = error;
21
+ failures.push({
22
+ mode,
23
+ reason: error instanceof Error ? error.message : String(error),
24
+ });
25
+ }
26
+ }
27
+ throw lastError instanceof Error ? lastError : new Error("Failed to render Telegram block");
28
+ }
@@ -0,0 +1,295 @@
1
+ import { toString } from "mdast-util-to-string";
2
+ import { unified } from "unified";
3
+ import remarkGfm from "remark-gfm";
4
+ import remarkParse from "remark-parse";
5
+ import { normalizeMarkdownForTelegramBlockParsing } from "./markdown-normalizer.js";
6
+ const markdownProcessor = unified().use(remarkParse).use(remarkGfm);
7
+ function pushTextNode(nodes, text) {
8
+ if (!text) {
9
+ return;
10
+ }
11
+ const previous = nodes.at(-1);
12
+ if (previous?.type === "text") {
13
+ previous.text += text;
14
+ return;
15
+ }
16
+ nodes.push({ type: "text", text });
17
+ }
18
+ function appendInlineNodes(target, additions) {
19
+ for (const node of additions) {
20
+ if (node.type === "text") {
21
+ pushTextNode(target, node.text);
22
+ continue;
23
+ }
24
+ target.push(node);
25
+ }
26
+ }
27
+ function prefixLines(text, prefix) {
28
+ return text
29
+ .split("\n")
30
+ .map((line) => `${prefix}${line}`)
31
+ .join("\n");
32
+ }
33
+ function createPlainBlock(text) {
34
+ const normalized = text.trim();
35
+ if (!normalized) {
36
+ return [];
37
+ }
38
+ return [{ type: "plain", text: normalized }];
39
+ }
40
+ function extractInlinePlainText(nodes) {
41
+ let result = "";
42
+ for (const node of nodes) {
43
+ switch (node.type) {
44
+ case "text":
45
+ result += node.value;
46
+ break;
47
+ case "strong":
48
+ case "emphasis":
49
+ case "delete":
50
+ case "link":
51
+ result += extractInlinePlainText(node.children);
52
+ break;
53
+ case "inlineCode":
54
+ result += node.value;
55
+ break;
56
+ case "break":
57
+ result += "\n";
58
+ break;
59
+ case "image":
60
+ result += node.alt ?? "";
61
+ break;
62
+ case "imageReference":
63
+ result += node.alt ?? "";
64
+ break;
65
+ case "linkReference":
66
+ result += extractInlinePlainText(node.children);
67
+ break;
68
+ case "html":
69
+ result += node.value;
70
+ break;
71
+ case "footnoteReference":
72
+ result += `[^${node.identifier}]`;
73
+ break;
74
+ default:
75
+ result += toString(node);
76
+ break;
77
+ }
78
+ }
79
+ return result;
80
+ }
81
+ function extractTableCellPlainText(cell) {
82
+ return extractInlinePlainText(cell.children);
83
+ }
84
+ function extractListItemPlainText(item, index, ordered) {
85
+ const prefix = item.checked === true ? "✅ " : item.checked === false ? "🔲 " : "";
86
+ const marker = ordered ? `${index + 1}. ` : "- ";
87
+ const body = item.children.map(extractBlockPlainText).filter(Boolean).join("\n");
88
+ return `${marker}${prefix}${body}`.trimEnd();
89
+ }
90
+ function extractBlockPlainText(node) {
91
+ switch (node.type) {
92
+ case "paragraph":
93
+ case "heading":
94
+ return extractInlinePlainText(node.children);
95
+ case "blockquote":
96
+ return node.children
97
+ .map(extractBlockPlainText)
98
+ .filter(Boolean)
99
+ .map((text) => prefixLines(text, "> "))
100
+ .join("\n");
101
+ case "list":
102
+ return node.children
103
+ .map((item, index) => extractListItemPlainText(item, index, Boolean(node.ordered)))
104
+ .filter(Boolean)
105
+ .join("\n");
106
+ case "listItem":
107
+ return node.children.map(extractBlockPlainText).filter(Boolean).join("\n");
108
+ case "code":
109
+ return node.value;
110
+ case "table":
111
+ return node.children
112
+ .map((row) => row.children.map(extractTableCellPlainText).join(" | "))
113
+ .join("\n");
114
+ case "thematicBreak":
115
+ return "──────────";
116
+ case "html":
117
+ return node.value;
118
+ default:
119
+ return toString(node);
120
+ }
121
+ }
122
+ function parseInlineNodes(nodes) {
123
+ const result = [];
124
+ for (const node of nodes) {
125
+ switch (node.type) {
126
+ case "text":
127
+ pushTextNode(result, node.value);
128
+ break;
129
+ case "strong": {
130
+ const children = parseInlineNodes(node.children);
131
+ if (!children) {
132
+ return null;
133
+ }
134
+ result.push({ type: "bold", children });
135
+ break;
136
+ }
137
+ case "emphasis": {
138
+ const children = parseInlineNodes(node.children);
139
+ if (!children) {
140
+ return null;
141
+ }
142
+ result.push({ type: "italic", children });
143
+ break;
144
+ }
145
+ case "delete": {
146
+ const children = parseInlineNodes(node.children);
147
+ if (!children) {
148
+ return null;
149
+ }
150
+ result.push({ type: "strike", children });
151
+ break;
152
+ }
153
+ case "inlineCode":
154
+ result.push({ type: "code", text: node.value });
155
+ break;
156
+ case "link": {
157
+ const children = parseInlineNodes(node.children);
158
+ if (!children) {
159
+ return null;
160
+ }
161
+ result.push({ type: "link", text: children, url: node.url });
162
+ break;
163
+ }
164
+ case "break":
165
+ pushTextNode(result, "\n");
166
+ break;
167
+ default:
168
+ return null;
169
+ }
170
+ }
171
+ return result;
172
+ }
173
+ function parseParagraphBlock(node) {
174
+ const inlines = parseInlineNodes(node.children);
175
+ if (!inlines) {
176
+ return createPlainBlock(extractBlockPlainText(node));
177
+ }
178
+ return [{ type: "paragraph", inlines }];
179
+ }
180
+ function parseHeadingBlock(node) {
181
+ const inlines = parseInlineNodes(node.children);
182
+ if (!inlines) {
183
+ return createPlainBlock(extractBlockPlainText(node));
184
+ }
185
+ return [
186
+ {
187
+ type: "heading",
188
+ level: Math.min(6, Math.max(1, Math.floor(node.depth))),
189
+ inlines,
190
+ },
191
+ ];
192
+ }
193
+ function parseBlockquoteBlock(node) {
194
+ const lines = [];
195
+ for (const child of node.children) {
196
+ if (child.type !== "paragraph" && child.type !== "heading") {
197
+ return createPlainBlock(extractBlockPlainText(node));
198
+ }
199
+ const inlines = parseInlineNodes(child.children);
200
+ if (!inlines) {
201
+ return createPlainBlock(extractBlockPlainText(node));
202
+ }
203
+ lines.push(inlines);
204
+ }
205
+ if (lines.length === 0) {
206
+ return [];
207
+ }
208
+ return [{ type: "blockquote", lines }];
209
+ }
210
+ function parseListItemInlines(item) {
211
+ const result = [];
212
+ const taskPrefix = item.checked === true ? "✅ " : item.checked === false ? "🔲 " : "";
213
+ if (taskPrefix) {
214
+ pushTextNode(result, taskPrefix);
215
+ }
216
+ for (let index = 0; index < item.children.length; index++) {
217
+ const child = item.children[index];
218
+ if (child.type !== "paragraph") {
219
+ return null;
220
+ }
221
+ const inlines = parseInlineNodes(child.children);
222
+ if (!inlines) {
223
+ return null;
224
+ }
225
+ if (index > 0) {
226
+ pushTextNode(result, "\n");
227
+ }
228
+ appendInlineNodes(result, inlines);
229
+ }
230
+ return result;
231
+ }
232
+ function parseListBlock(node) {
233
+ const items = [];
234
+ for (const item of node.children) {
235
+ const parsedItem = parseListItemInlines(item);
236
+ if (!parsedItem) {
237
+ return createPlainBlock(extractBlockPlainText(node));
238
+ }
239
+ items.push(parsedItem);
240
+ }
241
+ if (items.length === 0) {
242
+ return [];
243
+ }
244
+ return [{ type: "list", ordered: Boolean(node.ordered), items }];
245
+ }
246
+ function parseCodeBlock(node) {
247
+ return [{ type: "code", language: node.lang ?? undefined, text: node.value }];
248
+ }
249
+ function parseTableBlock(node) {
250
+ const rows = node.children.map((row) => row.children.map(extractTableCellPlainText));
251
+ if (rows.length === 0) {
252
+ return [];
253
+ }
254
+ return [{ type: "table", rows }];
255
+ }
256
+ function parseRootContent(node) {
257
+ switch (node.type) {
258
+ case "paragraph":
259
+ return parseParagraphBlock(node);
260
+ case "heading":
261
+ return parseHeadingBlock(node);
262
+ case "blockquote":
263
+ return parseBlockquoteBlock(node);
264
+ case "list":
265
+ return parseListBlock(node);
266
+ case "code":
267
+ return parseCodeBlock(node);
268
+ case "table":
269
+ return parseTableBlock(node);
270
+ case "thematicBreak":
271
+ return [{ type: "rule" }];
272
+ default:
273
+ return createPlainBlock(extractBlockPlainText(node));
274
+ }
275
+ }
276
+ function mergeAdjacentBlocks(blocks) {
277
+ const merged = [];
278
+ for (const block of blocks) {
279
+ const previous = merged.at(-1);
280
+ if (previous?.type === "blockquote" && block.type === "blockquote") {
281
+ previous.lines.push(...block.lines);
282
+ continue;
283
+ }
284
+ merged.push(block);
285
+ }
286
+ return merged;
287
+ }
288
+ export function parseTelegramBlocks(markdown) {
289
+ const normalized = normalizeMarkdownForTelegramBlockParsing(markdown).trim();
290
+ if (!normalized) {
291
+ return [];
292
+ }
293
+ const tree = markdownProcessor.parse(normalized);
294
+ return mergeAdjacentBlocks(tree.children.flatMap(parseRootContent));
295
+ }