@grinev/opencode-telegram-bot 0.7.0 → 0.9.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
@@ -40,7 +40,8 @@ OPENCODE_MODEL_ID=big-pickle
40
40
  # Maximum number of projects shown in /projects (default: 10)
41
41
  # PROJECTS_LIST_LIMIT=10
42
42
 
43
- # Bot locale: en or ru (default: en)
43
+ # Bot locale: supported locale code (default: en)
44
+ # Supported locales: en, de, es, ru, zh
44
45
  # BOT_LOCALE=en
45
46
 
46
47
  # Service message batching interval in seconds (thinking + tool calls, default: 5)
@@ -54,6 +55,11 @@ OPENCODE_MODEL_ID=big-pickle
54
55
  # Hide tool call service messages (default: false)
55
56
  # HIDE_TOOL_CALL_MESSAGES=false
56
57
 
58
+ # Assistant message formatting mode (default: markdown)
59
+ # markdown = convert assistant replies to Telegram MarkdownV2
60
+ # raw = show assistant replies as plain text
61
+ # MESSAGE_FORMAT_MODE=markdown
62
+
57
63
  # Code File Settings (optional)
58
64
  # Maximum file size in KB to send as document (default: 100)
59
65
  # CODE_FILE_MAX_SIZE_KB=100
package/README.md CHANGED
@@ -11,6 +11,8 @@ Run AI coding tasks, monitor progress, switch models, and manage sessions from y
11
11
 
12
12
  No open ports, no exposed APIs. The bot communicates with your local OpenCode server and the Telegram Bot API only.
13
13
 
14
+ Languages: English (`en`), Deutsch (`de`), Español (`es`), Русский (`ru`), 简体中文 (`zh`)
15
+
14
16
  Quick start: `npx @grinev/opencode-telegram-bot`
15
17
 
16
18
  <p align="center">
@@ -28,8 +30,9 @@ Quick start: `npx @grinev/opencode-telegram-bot`
28
30
  - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
29
31
  - **Context control** — compact context when it gets too large, right from the chat
30
32
  - **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions
33
+ - **Configurable reply formatting** — assistant replies use Telegram MarkdownV2 by default, with optional raw mode (`MESSAGE_FORMAT_MODE=markdown|raw`)
31
34
  - **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
32
- - **Localization** — English and Russian UI (`BOT_LOCALE=en|ru`)
35
+ - **Localization** — English, German, Spanish, Russian, and Simplified Chinese UI (`BOT_LOCALE=en|de|es|ru|zh`)
33
36
 
34
37
  ## Prerequisites
35
38
 
@@ -65,7 +68,7 @@ The fastest way — run directly with `npx`:
65
68
  npx @grinev/opencode-telegram-bot
66
69
  ```
67
70
 
68
- On first launch, an interactive wizard will guide you through the configuration — it will ask for your bot token, user ID, and OpenCode API URL. After that, you're ready to go. Open your bot in Telegram and start sending tasks.
71
+ On first launch, an interactive wizard will guide you through the configuration — it asks for interface language first, then your bot token, user ID, and OpenCode API URL. After that, you're ready to go. Open your bot in Telegram and start sending tasks.
69
72
 
70
73
  #### Alternative: Global Install
71
74
 
@@ -110,6 +113,12 @@ Any regular text message is sent as a prompt to the coding agent only when no bl
110
113
 
111
114
  ## Configuration
112
115
 
116
+ ### Localization
117
+
118
+ - Supported locales: `en`, `de`, `es`, `ru`, `zh`
119
+ - The setup wizard asks for language first
120
+ - You can change locale later with `BOT_LOCALE`
121
+
113
122
  ### Environment Variables
114
123
 
115
124
  When installed via npm, the configuration wizard handles the initial setup. The `.env` file is stored in your platform's app data directory:
@@ -128,12 +137,13 @@ When installed via npm, the configuration wizard handles the initial setup. The
128
137
  | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
129
138
  | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
130
139
  | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
131
- | `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
140
+ | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `ru`, `zh`) | No | `en` |
132
141
  | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
133
142
  | `PROJECTS_LIST_LIMIT` | Max projects shown in `/projects` | No | `10` |
134
143
  | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
135
144
  | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
136
145
  | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
146
+ | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
137
147
  | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
138
148
  | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
139
149
  | `STT_API_KEY` | API key for your STT provider | No | — |
@@ -10,7 +10,7 @@ import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-
10
10
  import { logger } from "../../utils/logger.js";
11
11
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
12
12
  import { config } from "../../config.js";
13
- import { getLocale, t } from "../../i18n/index.js";
13
+ import { getDateLocale, t } from "../../i18n/index.js";
14
14
  export async function sessionsCommand(ctx) {
15
15
  try {
16
16
  const maxSessions = config.bot.sessionsListLimit;
@@ -36,7 +36,7 @@ export async function sessionsCommand(ctx) {
36
36
  return;
37
37
  }
38
38
  const keyboard = new InlineKeyboard();
39
- const localeForDate = getLocale() === "ru" ? "ru-RU" : "en-US";
39
+ const localeForDate = getDateLocale();
40
40
  sessions.forEach((session, index) => {
41
41
  const date = new Date(session.time.created).toLocaleDateString(localeForDate);
42
42
  const label = `${index + 1}. ${session.title} (${date})`;
@@ -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
@@ -1,5 +1,6 @@
1
1
  import dotenv from "dotenv";
2
2
  import { getRuntimePaths } from "./runtime/paths.js";
3
+ import { normalizeLocale } from "./i18n/index.js";
3
4
  const runtimePaths = getRuntimePaths();
4
5
  dotenv.config({ path: runtimePaths.envFilePath });
5
6
  function getEnvVar(key, required = true) {
@@ -36,17 +37,7 @@ function getOptionalNonNegativeIntEnvVarFromKeys(keys, defaultValue) {
36
37
  }
37
38
  function getOptionalLocaleEnvVar(key, defaultValue) {
38
39
  const value = getEnvVar(key, false);
39
- if (!value) {
40
- return defaultValue;
41
- }
42
- const normalized = value.trim().toLowerCase().split("-")[0];
43
- if (normalized === "ru") {
44
- return "ru";
45
- }
46
- if (normalized === "en") {
47
- return "en";
48
- }
49
- return defaultValue;
40
+ return normalizeLocale(value, defaultValue);
50
41
  }
51
42
  function getOptionalBooleanEnvVar(key, defaultValue) {
52
43
  const value = getEnvVar(key, false);
@@ -62,6 +53,17 @@ function getOptionalBooleanEnvVar(key, defaultValue) {
62
53
  }
63
54
  return defaultValue;
64
55
  }
56
+ function getOptionalMessageFormatModeEnvVar(key, defaultValue) {
57
+ const value = getEnvVar(key, false);
58
+ if (!value) {
59
+ return defaultValue;
60
+ }
61
+ const normalized = value.trim().toLowerCase();
62
+ if (normalized === "raw" || normalized === "markdown") {
63
+ return normalized;
64
+ }
65
+ return defaultValue;
66
+ }
65
67
  export const config = {
66
68
  telegram: {
67
69
  token: getEnvVar("TELEGRAM_BOT_TOKEN"),
@@ -87,6 +89,7 @@ export const config = {
87
89
  serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
88
90
  hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
89
91
  hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
92
+ messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"),
90
93
  },
91
94
  files: {
92
95
  maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),