@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 +5 -0
- package/README.md +2 -0
- 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 +12 -0
- package/dist/i18n/en.js +6 -0
- package/dist/i18n/ru.js +6 -0
- package/dist/interaction/guard.js +4 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/summary/formatter.js +112 -0
- package/package.json +3 -2
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
|
|
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
|
@@ -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.
|
|
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",
|