@grinev/opencode-telegram-bot 0.7.0 → 0.8.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.
package/.env.example CHANGED
@@ -54,6 +54,11 @@ OPENCODE_MODEL_ID=big-pickle
54
54
  # Hide tool call service messages (default: false)
55
55
  # HIDE_TOOL_CALL_MESSAGES=false
56
56
 
57
+ # Assistant message formatting mode (default: markdown)
58
+ # markdown = convert assistant replies to Telegram MarkdownV2
59
+ # raw = show assistant replies as plain text
60
+ # MESSAGE_FORMAT_MODE=markdown
61
+
57
62
  # Code File Settings (optional)
58
63
  # Maximum file size in KB to send as document (default: 100)
59
64
  # CODE_FILE_MAX_SIZE_KB=100
package/README.md CHANGED
@@ -28,6 +28,7 @@ Quick start: `npx @grinev/opencode-telegram-bot`
28
28
  - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
29
29
  - **Context control** — compact context when it gets too large, right from the chat
30
30
  - **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions
31
+ - **Configurable reply formatting** — assistant replies use Telegram MarkdownV2 by default, with optional raw mode (`MESSAGE_FORMAT_MODE=markdown|raw`)
31
32
  - **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
32
33
  - **Localization** — English and Russian UI (`BOT_LOCALE=en|ru`)
33
34
 
@@ -134,6 +135,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
134
135
  | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
135
136
  | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
136
137
  | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
138
+ | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
137
139
  | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
138
140
  | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
139
141
  | `STT_API_KEY` | API key for your STT provider | No | — |
@@ -7,8 +7,20 @@ import { createMainKeyboard } from "../utils/keyboard.js";
7
7
  import { getStoredAgent } from "../../agent/manager.js";
8
8
  import { pinnedMessageManager } from "../../pinned/manager.js";
9
9
  import { keyboardManager } from "../../keyboard/manager.js";
10
+ import { config } from "../../config.js";
10
11
  import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
11
12
  import { t } from "../../i18n/index.js";
13
+ function isOnlyConfigDefaultModel(models) {
14
+ if (models.length !== 1) {
15
+ return false;
16
+ }
17
+ const defaultProviderID = config.opencode.model.provider;
18
+ const defaultModelID = config.opencode.model.modelId;
19
+ if (!defaultProviderID || !defaultModelID) {
20
+ return false;
21
+ }
22
+ return models[0].providerID === defaultProviderID && models[0].modelID === defaultModelID;
23
+ }
12
24
  /**
13
25
  * Handle model selection callback
14
26
  * @param ctx grammY context
@@ -83,9 +95,9 @@ export async function handleModelSelect(ctx) {
83
95
  * @param currentModel Current model for highlighting
84
96
  * @returns InlineKeyboard with model selection buttons
85
97
  */
86
- export async function buildModelSelectionMenu(currentModel) {
98
+ export async function buildModelSelectionMenu(currentModel, favoriteModels) {
87
99
  const keyboard = new InlineKeyboard();
88
- const favorites = await getFavoriteModels();
100
+ const favorites = favoriteModels ?? (await getFavoriteModels());
89
101
  if (favorites.length === 0) {
90
102
  logger.warn("[ModelHandler] No favorite models found");
91
103
  return keyboard;
@@ -109,7 +121,8 @@ export async function buildModelSelectionMenu(currentModel) {
109
121
  export async function showModelSelectionMenu(ctx) {
110
122
  try {
111
123
  const currentModel = fetchCurrentModel();
112
- const keyboard = await buildModelSelectionMenu(currentModel);
124
+ const favorites = await getFavoriteModels();
125
+ const keyboard = await buildModelSelectionMenu(currentModel, favorites);
113
126
  if (keyboard.inline_keyboard.length === 0) {
114
127
  await ctx.reply(t("model.menu.empty"));
115
128
  return;
@@ -121,6 +134,9 @@ export async function showModelSelectionMenu(ctx) {
121
134
  text,
122
135
  keyboard,
123
136
  });
137
+ if (isOnlyConfigDefaultModel(favorites)) {
138
+ await ctx.reply(t("model.menu.favorites_hint"));
139
+ }
124
140
  }
125
141
  catch (err) {
126
142
  logger.error("[ModelHandler] Error showing model menu:", err);
@@ -62,11 +62,15 @@ async function resetMismatchedSessionContext() {
62
62
  }
63
63
  /**
64
64
  * Processes a user prompt: ensures project/session, subscribes to events, and sends
65
- * the prompt to OpenCode. Used by both text and voice message handlers.
65
+ * the prompt to OpenCode. Used by text, voice, and photo message handlers.
66
66
  *
67
+ * @param ctx - Grammy context
68
+ * @param text - Text content of the prompt
69
+ * @param deps - Dependencies (bot and event subscription)
70
+ * @param fileParts - Optional file parts (for photo/document attachments)
67
71
  * @returns true if the prompt was dispatched, false if it was blocked/failed early.
68
72
  */
69
- export async function processUserPrompt(ctx, text, deps) {
73
+ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
70
74
  const { bot, ensureEventSubscription } = deps;
71
75
  const currentProject = getCurrentProject();
72
76
  if (!currentProject) {
@@ -145,10 +149,25 @@ export async function processUserPrompt(ctx, text, deps) {
145
149
  try {
146
150
  const currentAgent = getStoredAgent();
147
151
  const storedModel = getStoredModel();
152
+ // Build parts array with text and files
153
+ const parts = [];
154
+ // Add text part if present
155
+ if (text.trim().length > 0) {
156
+ parts.push({ type: "text", text });
157
+ }
158
+ // Add file parts
159
+ parts.push(...fileParts);
160
+ // If no text and files exist, use a placeholder
161
+ if (parts.length === 0 || (parts.length > 0 && parts.every((p) => p.type === "file"))) {
162
+ if (fileParts.length > 0) {
163
+ // Files without text - add a minimal system prompt
164
+ parts.unshift({ type: "text", text: "See attached file" });
165
+ }
166
+ }
148
167
  const promptOptions = {
149
168
  sessionID: currentSession.id,
150
169
  directory: currentSession.directory,
151
- parts: [{ type: "text", text }],
170
+ parts,
152
171
  agent: currentAgent,
153
172
  };
154
173
  // Use stored model (from settings or config)
@@ -170,8 +189,9 @@ export async function processUserPrompt(ctx, text, deps) {
170
189
  modelId: storedModel.modelID || "default",
171
190
  variant: storedModel.variant || "default",
172
191
  promptLength: text.length,
192
+ fileCount: fileParts.length,
173
193
  };
174
- logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}...`);
194
+ logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
175
195
  // CRITICAL: DO NOT wait for session.prompt to complete.
176
196
  // If we wait, the handler will not finish and grammY will not call getUpdates,
177
197
  // which blocks receiving button callback_query updates.
package/dist/bot/index.js CHANGED
@@ -35,7 +35,7 @@ import { clearAllInteractionState } from "../interaction/cleanup.js";
35
35
  import { keyboardManager } from "../keyboard/manager.js";
36
36
  import { subscribeToEvents } from "../opencode/events.js";
37
37
  import { summaryAggregator } from "../summary/aggregator.js";
38
- import { formatSummary, formatToolInfo } from "../summary/formatter.js";
38
+ import { formatSummary, formatToolInfo, getAssistantParseMode } from "../summary/formatter.js";
39
39
  import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
40
40
  import { getCurrentSession } from "../session/manager.js";
41
41
  import { ingestSessionInfoForCache } from "../session/cache-manager.js";
@@ -45,6 +45,10 @@ import { pinnedMessageManager } from "../pinned/manager.js";
45
45
  import { t } from "../i18n/index.js";
46
46
  import { processUserPrompt } from "./handlers/prompt.js";
47
47
  import { handleVoiceMessage } from "./handlers/voice.js";
48
+ import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
49
+ import { sendMessageWithMarkdownFallback } from "./utils/send-with-markdown-fallback.js";
50
+ import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
51
+ import { getStoredModel } from "../model/manager.js";
48
52
  let botInstance = null;
49
53
  let chatIdInstance = null;
50
54
  let commandsInitialized = false;
@@ -145,24 +149,19 @@ async function ensureEventSubscription(directory) {
145
149
  await toolMessageBatcher.flushSession(sessionId, "assistant_message_completed");
146
150
  try {
147
151
  const parts = formatSummary(messageText);
152
+ const assistantParseMode = getAssistantParseMode();
148
153
  logger.debug(`[Bot] Sending completed message to Telegram (chatId=${chatIdInstance}, parts=${parts.length})`);
149
154
  for (let i = 0; i < parts.length; i++) {
150
155
  const isLastPart = i === parts.length - 1;
151
- if (isLastPart && keyboardManager.isInitialized()) {
152
- // Attach updated keyboard to the last message part (only if initialized)
153
- const keyboard = keyboardManager.getKeyboard();
154
- if (keyboard) {
155
- await botInstance.api.sendMessage(chatIdInstance, parts[i], {
156
- reply_markup: keyboard,
157
- });
158
- }
159
- else {
160
- await botInstance.api.sendMessage(chatIdInstance, parts[i]);
161
- }
162
- }
163
- else {
164
- await botInstance.api.sendMessage(chatIdInstance, parts[i]);
165
- }
156
+ const keyboard = isLastPart && keyboardManager.isInitialized() ? keyboardManager.getKeyboard() : undefined;
157
+ const options = keyboard ? { reply_markup: keyboard } : undefined;
158
+ await sendMessageWithMarkdownFallback({
159
+ api: botInstance.api,
160
+ chatId: chatIdInstance,
161
+ text: parts[i],
162
+ options,
163
+ parseMode: assistantParseMode,
164
+ });
166
165
  }
167
166
  }
168
167
  catch (err) {
@@ -584,6 +583,56 @@ export function createBot() {
584
583
  chatIdInstance = ctx.chat.id;
585
584
  await handleVoiceMessage(ctx, voicePromptDeps);
586
585
  });
586
+ // Photo message handler
587
+ bot.on("message:photo", async (ctx) => {
588
+ logger.debug(`[Bot] Received photo message, chatId=${ctx.chat.id}`);
589
+ const photos = ctx.message?.photo;
590
+ if (!photos || photos.length === 0) {
591
+ return;
592
+ }
593
+ const caption = ctx.message.caption || "";
594
+ try {
595
+ // Get the largest photo (last element in array)
596
+ const largestPhoto = photos[photos.length - 1];
597
+ // Check model capabilities
598
+ const storedModel = getStoredModel();
599
+ const capabilities = await getModelCapabilities(storedModel.providerID, storedModel.modelID);
600
+ if (!supportsInput(capabilities, "image")) {
601
+ logger.warn(`[Bot] Model ${storedModel.providerID}/${storedModel.modelID} doesn't support image input`);
602
+ await ctx.reply(t("bot.photo_model_no_image"));
603
+ // Fall back to caption-only if present
604
+ if (caption.trim().length > 0) {
605
+ botInstance = bot;
606
+ chatIdInstance = ctx.chat.id;
607
+ const promptDeps = { bot, ensureEventSubscription };
608
+ await processUserPrompt(ctx, caption, promptDeps);
609
+ }
610
+ return;
611
+ }
612
+ // Download photo
613
+ await ctx.reply(t("bot.photo_downloading"));
614
+ const downloadedFile = await downloadTelegramFile(ctx.api, largestPhoto.file_id);
615
+ // Convert to data URI (Telegram always converts photos to JPEG)
616
+ const dataUri = toDataUri(downloadedFile.buffer, "image/jpeg");
617
+ // Create file part
618
+ const filePart = {
619
+ type: "file",
620
+ mime: "image/jpeg",
621
+ filename: "photo.jpg",
622
+ url: dataUri,
623
+ };
624
+ logger.info(`[Bot] Sending photo (${downloadedFile.buffer.length} bytes) with prompt`);
625
+ botInstance = bot;
626
+ chatIdInstance = ctx.chat.id;
627
+ // Send via processUserPrompt with file part
628
+ const promptDeps = { bot, ensureEventSubscription };
629
+ await processUserPrompt(ctx, caption, promptDeps, [filePart]);
630
+ }
631
+ catch (err) {
632
+ logger.error("[Bot] Error handling photo message:", err);
633
+ await ctx.reply(t("bot.photo_download_error"));
634
+ }
635
+ });
587
636
  bot.on("message:text", async (ctx) => {
588
637
  const text = ctx.message?.text;
589
638
  if (!text) {
@@ -0,0 +1,75 @@
1
+ import { config } from "../../config.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ const TELEGRAM_FILE_URL_BASE = "https://api.telegram.org/file/bot";
4
+ const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB Telegram limit
5
+ /**
6
+ * Download a photo from Telegram servers
7
+ * @param api Grammy API instance
8
+ * @param fileId Telegram file_id
9
+ * @returns Downloaded photo buffer and path
10
+ */
11
+ export async function downloadTelegramFile(api, fileId) {
12
+ logger.debug(`[FileDownload] Getting file info for fileId=${fileId}`);
13
+ const file = await api.getFile(fileId);
14
+ if (!file.file_path) {
15
+ throw new Error("File path not available from Telegram");
16
+ }
17
+ if (file.file_size && file.file_size > MAX_FILE_SIZE_BYTES) {
18
+ const sizeMb = (file.file_size / (1024 * 1024)).toFixed(2);
19
+ throw new Error(`File too large: ${sizeMb}MB (max 20MB)`);
20
+ }
21
+ const fileUrl = `${TELEGRAM_FILE_URL_BASE}${config.telegram.token}/${file.file_path}`;
22
+ logger.debug(`[FileDownload] Downloading from ${fileUrl.replace(config.telegram.token, "***")}`);
23
+ const fetchOptions = {};
24
+ // Use proxy if configured
25
+ if (config.telegram.proxyUrl) {
26
+ const { HttpsProxyAgent } = await import("https-proxy-agent");
27
+ fetchOptions.agent = new HttpsProxyAgent(config.telegram.proxyUrl);
28
+ }
29
+ const response = await fetch(fileUrl, fetchOptions);
30
+ if (!response.ok) {
31
+ throw new Error(`Failed to download file: ${response.status} ${response.statusText}`);
32
+ }
33
+ const arrayBuffer = await response.arrayBuffer();
34
+ const buffer = Buffer.from(arrayBuffer);
35
+ logger.debug(`[FileDownload] Downloaded ${buffer.length} bytes`);
36
+ return {
37
+ buffer,
38
+ filePath: file.file_path,
39
+ };
40
+ }
41
+ /**
42
+ * Convert buffer to base64 data URI
43
+ * @param buffer File buffer
44
+ * @param mimeType MIME type (e.g., "image/jpeg")
45
+ * @returns Data URI string
46
+ */
47
+ export function toDataUri(buffer, mimeType) {
48
+ const base64 = buffer.toString("base64");
49
+ return `data:${mimeType};base64,${base64}`;
50
+ }
51
+ /**
52
+ * Check if photo size is within limits
53
+ * @param fileSize Photo size in bytes
54
+ * @param maxSizeKb Maximum size in KB (from config)
55
+ * @returns true if within limit
56
+ */
57
+ export function isFileSizeAllowed(fileSize, maxSizeKb) {
58
+ if (!fileSize) {
59
+ return true; // Unknown size, allow (will be checked on download)
60
+ }
61
+ const maxBytes = maxSizeKb * 1024;
62
+ return fileSize <= maxBytes;
63
+ }
64
+ /**
65
+ * Get human-readable photo size
66
+ */
67
+ export function formatFileSize(bytes) {
68
+ if (bytes < 1024) {
69
+ return `${bytes}B`;
70
+ }
71
+ if (bytes < 1024 * 1024) {
72
+ return `${(bytes / 1024).toFixed(1)}KB`;
73
+ }
74
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
75
+ }
@@ -0,0 +1,58 @@
1
+ import { logger } from "../../utils/logger.js";
2
+ const MARKDOWN_PARSE_ERROR_MARKERS = [
3
+ "can't parse entities",
4
+ "can't parse entity",
5
+ "can't find end of the entity",
6
+ "entity beginning",
7
+ "bad request: can't parse",
8
+ ];
9
+ function getErrorText(error) {
10
+ const parts = [];
11
+ if (error instanceof Error) {
12
+ parts.push(error.message);
13
+ }
14
+ if (typeof error === "object" && error !== null) {
15
+ const description = Reflect.get(error, "description");
16
+ if (typeof description === "string") {
17
+ parts.push(description);
18
+ }
19
+ const message = Reflect.get(error, "message");
20
+ if (typeof message === "string") {
21
+ parts.push(message);
22
+ }
23
+ }
24
+ if (typeof error === "string") {
25
+ parts.push(error);
26
+ }
27
+ if (parts.length === 0) {
28
+ return "";
29
+ }
30
+ return parts.join("\n").toLowerCase();
31
+ }
32
+ export function isTelegramMarkdownParseError(error) {
33
+ const errorText = getErrorText(error);
34
+ if (!errorText) {
35
+ return false;
36
+ }
37
+ return MARKDOWN_PARSE_ERROR_MARKERS.some((marker) => errorText.includes(marker));
38
+ }
39
+ export async function sendMessageWithMarkdownFallback({ api, chatId, text, options, parseMode, }) {
40
+ if (!parseMode) {
41
+ await api.sendMessage(chatId, text, options);
42
+ return;
43
+ }
44
+ const markdownOptions = {
45
+ ...(options || {}),
46
+ parse_mode: parseMode,
47
+ };
48
+ try {
49
+ await api.sendMessage(chatId, text, markdownOptions);
50
+ }
51
+ catch (error) {
52
+ if (!isTelegramMarkdownParseError(error)) {
53
+ throw error;
54
+ }
55
+ logger.warn("[Bot] MarkdownV2 parse failed, retrying assistant message in raw mode", error);
56
+ await api.sendMessage(chatId, text, options);
57
+ }
58
+ }
package/dist/config.js CHANGED
@@ -62,6 +62,17 @@ function getOptionalBooleanEnvVar(key, defaultValue) {
62
62
  }
63
63
  return defaultValue;
64
64
  }
65
+ function getOptionalMessageFormatModeEnvVar(key, defaultValue) {
66
+ const value = getEnvVar(key, false);
67
+ if (!value) {
68
+ return defaultValue;
69
+ }
70
+ const normalized = value.trim().toLowerCase();
71
+ if (normalized === "raw" || normalized === "markdown") {
72
+ return normalized;
73
+ }
74
+ return defaultValue;
75
+ }
65
76
  export const config = {
66
77
  telegram: {
67
78
  token: getEnvVar("TELEGRAM_BOT_TOKEN"),
@@ -87,6 +98,7 @@ export const config = {
87
98
  serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
88
99
  hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
89
100
  hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
101
+ messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"),
90
102
  },
91
103
  files: {
92
104
  maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),
package/dist/i18n/en.js CHANGED
@@ -43,6 +43,11 @@ export const en = {
43
43
  "bot.prompt_send_error": "Failed to send request to OpenCode.",
44
44
  "bot.session_error": "🔴 OpenCode returned an error: {message}",
45
45
  "bot.unknown_command": "⚠️ Unknown command: {command}. Use /help to see available commands.",
46
+ "bot.photo_downloading": "⏳ Downloading photo...",
47
+ "bot.photo_too_large": "⚠️ Photo is too large (max {maxSizeMb}MB)",
48
+ "bot.photo_model_no_image": "⚠️ Current model doesn't support image input. Sending text only.",
49
+ "bot.photo_download_error": "🔴 Failed to download photo",
50
+ "bot.photo_no_caption": "💡 Tip: Add a caption to describe what you want to do with this photo.",
46
51
  "status.header_running": "🟢 **OpenCode Server is running**",
47
52
  "status.health.healthy": "Healthy",
48
53
  "status.health.unhealthy": "Unhealthy",
@@ -115,6 +120,7 @@ export const en = {
115
120
  "model.change_error_callback": "Failed to change model",
116
121
  "model.menu.empty": "⚠️ No available models",
117
122
  "model.menu.current": "Current model: {name}\n\nSelect model:",
123
+ "model.menu.favorites_hint": "ℹ️ The model list is built from favorites in OpenCode CLI.",
118
124
  "model.menu.error": "🔴 Failed to get models list",
119
125
  "variant.model_not_selected_callback": "Error: model is not selected",
120
126
  "variant.changed_callback": "Variant changed: {name}",
package/dist/i18n/ru.js CHANGED
@@ -43,6 +43,11 @@ export const ru = {
43
43
  "bot.prompt_send_error": "Не удалось отправить запрос в OpenCode.",
44
44
  "bot.session_error": "🔴 OpenCode вернул ошибку: {message}",
45
45
  "bot.unknown_command": "⚠️ Неизвестная команда: {command}. Используйте /help для списка команд.",
46
+ "bot.photo_downloading": "⏳ Скачиваю фото...",
47
+ "bot.photo_too_large": "⚠️ Фото слишком большое (макс. {maxSizeMb}МБ)",
48
+ "bot.photo_model_no_image": "⚠️ Текущая модель не поддерживает изображения. Отправляю только текст.",
49
+ "bot.photo_download_error": "🔴 Не удалось скачать фото",
50
+ "bot.photo_no_caption": "💡 Совет: Добавьте подпись, чтобы описать, что делать с этим фото.",
46
51
  "status.header_running": "🟢 **OpenCode Server запущен**",
47
52
  "status.health.healthy": "Healthy",
48
53
  "status.health.unhealthy": "Unhealthy",
@@ -115,6 +120,7 @@ export const ru = {
115
120
  "model.change_error_callback": "Ошибка при смене модели",
116
121
  "model.menu.empty": "⚠️ Нет доступных моделей",
117
122
  "model.menu.current": "Текущая модель: {name}\n\nВыберите модель:",
123
+ "model.menu.favorites_hint": "ℹ️ Список моделей формируется из favorites в OpenCode CLI.",
118
124
  "model.menu.error": "🔴 Не удалось получить список моделей",
119
125
  "variant.model_not_selected_callback": "Ошибка: модель не выбрана",
120
126
  "variant.changed_callback": "Variant изменен: {name}",
@@ -23,6 +23,10 @@ function classifyIncomingInput(ctx) {
23
23
  }
24
24
  return { inputType: "text" };
25
25
  }
26
+ // Photo, voice, audio, and other non-text messages are classified as "other"
27
+ if (ctx.message?.photo) {
28
+ return { inputType: "other" };
29
+ }
26
30
  return { inputType: "other" };
27
31
  }
28
32
  function getExpectedInputBlockReason(expectedInput) {
@@ -0,0 +1,62 @@
1
+ import { opencodeClient } from "../opencode/client.js";
2
+ import { logger } from "../utils/logger.js";
3
+ const capabilitiesCache = {};
4
+ /**
5
+ * Get model capabilities from OpenCode API
6
+ * Results are cached in memory per model
7
+ */
8
+ export async function getModelCapabilities(providerID, modelID) {
9
+ const cacheKey = `${providerID}/${modelID}`;
10
+ if (capabilitiesCache[cacheKey] !== undefined) {
11
+ logger.debug(`[ModelCapabilities] Cache hit for ${cacheKey}`);
12
+ return capabilitiesCache[cacheKey];
13
+ }
14
+ try {
15
+ logger.debug(`[ModelCapabilities] Fetching capabilities for ${cacheKey}`);
16
+ const response = await opencodeClient.config.providers();
17
+ if (response.error || !response.data) {
18
+ logger.error("[ModelCapabilities] API returned error:", response.error);
19
+ capabilitiesCache[cacheKey] = null;
20
+ return null;
21
+ }
22
+ const providers = response.data.providers;
23
+ const provider = providers.find((p) => p.id === providerID);
24
+ if (!provider) {
25
+ logger.warn(`[ModelCapabilities] Provider ${providerID} not found`);
26
+ capabilitiesCache[cacheKey] = null;
27
+ return null;
28
+ }
29
+ const model = provider.models[modelID];
30
+ if (!model) {
31
+ logger.warn(`[ModelCapabilities] Model ${cacheKey} not found in provider`);
32
+ capabilitiesCache[cacheKey] = null;
33
+ return null;
34
+ }
35
+ logger.debug(`[ModelCapabilities] Found capabilities for ${cacheKey}`);
36
+ capabilitiesCache[cacheKey] = model.capabilities;
37
+ return model.capabilities;
38
+ }
39
+ catch (error) {
40
+ logger.error("[ModelCapabilities] Failed to fetch providers:", error);
41
+ capabilitiesCache[cacheKey] = null;
42
+ return null;
43
+ }
44
+ }
45
+ /**
46
+ * Check if model supports a specific input type
47
+ */
48
+ export function supportsInput(capabilities, inputType) {
49
+ if (!capabilities) {
50
+ return false;
51
+ }
52
+ return capabilities.input[inputType] === true;
53
+ }
54
+ /**
55
+ * Check if model supports attachments in general
56
+ */
57
+ export function supportsAttachment(capabilities) {
58
+ if (!capabilities) {
59
+ return false;
60
+ }
61
+ return capabilities.attachment === true;
62
+ }
@@ -1,4 +1,5 @@
1
1
  import * as path from "path";
2
+ import { convert } from "telegram-markdown-v2";
2
3
  import { config } from "../config.js";
3
4
  import { logger } from "../utils/logger.js";
4
5
  import { t } from "../i18n/index.js";
@@ -22,6 +23,87 @@ function splitText(text, maxLength) {
22
23
  }
23
24
  return parts;
24
25
  }
26
+ function isCodeFenceLine(line) {
27
+ return line.trimStart().startsWith("```");
28
+ }
29
+ function isHorizontalRuleLine(line) {
30
+ const normalized = line.trim();
31
+ if (!normalized) {
32
+ return false;
33
+ }
34
+ return /^([-*_])(?:\s*\1){2,}$/.test(normalized);
35
+ }
36
+ function isHeadingLine(line) {
37
+ return /^\s{0,3}#{1,6}\s+\S/.test(line);
38
+ }
39
+ function normalizeHeadingLine(line) {
40
+ const match = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/);
41
+ if (!match) {
42
+ return line;
43
+ }
44
+ return `**${match[1]}**`;
45
+ }
46
+ function normalizeChecklistLine(line) {
47
+ const match = line.match(/^(\s*)(?:[-+*]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/);
48
+ if (!match) {
49
+ return null;
50
+ }
51
+ const marker = match[2].toLowerCase() === "x" ? "✅" : "🔲";
52
+ return `${match[1]}${marker} ${match[3]}`;
53
+ }
54
+ function preprocessMarkdownForTelegram(text) {
55
+ const lines = text.split("\n");
56
+ const output = [];
57
+ let inCodeFence = false;
58
+ let inQuote = false;
59
+ for (let index = 0; index < lines.length; index++) {
60
+ const line = lines[index];
61
+ if (isCodeFenceLine(line)) {
62
+ inCodeFence = !inCodeFence;
63
+ inQuote = false;
64
+ output.push(line);
65
+ continue;
66
+ }
67
+ if (inCodeFence) {
68
+ output.push(line);
69
+ continue;
70
+ }
71
+ if (!line.trim()) {
72
+ inQuote = false;
73
+ output.push(line);
74
+ continue;
75
+ }
76
+ if (isHeadingLine(line)) {
77
+ output.push(normalizeHeadingLine(line));
78
+ inQuote = false;
79
+ continue;
80
+ }
81
+ if (isHorizontalRuleLine(line)) {
82
+ output.push("──────────");
83
+ inQuote = false;
84
+ continue;
85
+ }
86
+ const trimmedLeft = line.trimStart();
87
+ if (trimmedLeft.startsWith(">")) {
88
+ inQuote = true;
89
+ const quoteContent = trimmedLeft.replace(/^>\s?/, "");
90
+ const normalizedChecklistInQuote = normalizeChecklistLine(quoteContent);
91
+ output.push(normalizedChecklistInQuote ? `> ${normalizedChecklistInQuote.trimStart()}` : trimmedLeft);
92
+ continue;
93
+ }
94
+ const normalizedChecklist = normalizeChecklistLine(line);
95
+ if (normalizedChecklist) {
96
+ output.push(inQuote ? `> ${normalizedChecklist.trimStart()}` : normalizedChecklist);
97
+ continue;
98
+ }
99
+ if (inQuote) {
100
+ output.push(`> ${trimmedLeft}`);
101
+ continue;
102
+ }
103
+ output.push(line);
104
+ }
105
+ return output.join("\n");
106
+ }
25
107
  export function normalizePathForDisplay(filePath) {
26
108
  const normalizedPath = filePath.replace(/\\/g, "/");
27
109
  const project = getCurrentProject();
@@ -44,6 +126,25 @@ export function normalizePathForDisplay(filePath) {
44
126
  return normalizedPath;
45
127
  }
46
128
  export function formatSummary(text) {
129
+ return formatSummaryWithMode(text, config.bot.messageFormatMode);
130
+ }
131
+ export function getAssistantParseMode() {
132
+ if (config.bot.messageFormatMode === "markdown") {
133
+ return "MarkdownV2";
134
+ }
135
+ return undefined;
136
+ }
137
+ function formatMarkdownForTelegram(text) {
138
+ try {
139
+ const preprocessed = preprocessMarkdownForTelegram(text);
140
+ return convert(preprocessed, "keep");
141
+ }
142
+ catch (error) {
143
+ logger.warn("[Formatter] Failed to convert markdown summary, falling back to raw text", error);
144
+ return text;
145
+ }
146
+ }
147
+ export function formatSummaryWithMode(text, mode) {
47
148
  if (!text || text.trim().length === 0) {
48
149
  return [];
49
150
  }
@@ -54,6 +155,17 @@ export function formatSummary(text) {
54
155
  if (!trimmed) {
55
156
  continue;
56
157
  }
158
+ if (mode === "markdown") {
159
+ const converted = formatMarkdownForTelegram(trimmed);
160
+ const convertedParts = splitText(converted, TELEGRAM_MESSAGE_LIMIT);
161
+ for (const convertedPart of convertedParts) {
162
+ const normalizedPart = convertedPart.trim();
163
+ if (normalizedPart) {
164
+ formattedParts.push(normalizedPart);
165
+ }
166
+ }
167
+ continue;
168
+ }
57
169
  if (parts.length > 1) {
58
170
  formattedParts.push(`\`\`\`\n${trimmed}\n\`\`\``);
59
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -57,7 +57,8 @@
57
57
  "dotenv": "^17.2.3",
58
58
  "grammy": "^1.39.2",
59
59
  "https-proxy-agent": "^7.0.6",
60
- "socks-proxy-agent": "^8.0.5"
60
+ "socks-proxy-agent": "^8.0.5",
61
+ "telegram-markdown-v2": "^0.0.4"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@types/better-sqlite3": "^7.6.13",