@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 +7 -1
- package/README.md +13 -3
- package/dist/bot/commands/sessions.js +2 -2
- package/dist/bot/handlers/model.js +19 -3
- package/dist/bot/handlers/prompt.js +24 -4
- package/dist/bot/index.js +65 -16
- package/dist/bot/utils/file-download.js +75 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +58 -0
- package/dist/config.js +14 -11
- package/dist/i18n/de.js +247 -0
- package/dist/i18n/en.js +10 -1
- package/dist/i18n/es.js +247 -0
- package/dist/i18n/index.js +64 -12
- package/dist/i18n/ru.js +10 -1
- package/dist/i18n/zh.js +247 -0
- package/dist/interaction/guard.js +4 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/runtime/bootstrap.js +43 -1
- package/dist/session/cache-manager.js +56 -1
- package/dist/summary/formatter.js +117 -5
- package/package.json +3 -2
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:
|
|
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
|
|
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
|
|
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`
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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),
|