@grinev/opencode-telegram-bot 0.6.1 → 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 +18 -0
- package/README.md +50 -26
- package/dist/bot/commands/projects.js +2 -2
- package/dist/bot/handlers/model.js +19 -3
- package/dist/bot/handlers/prompt.js +232 -0
- package/dist/bot/handlers/voice.js +170 -0
- package/dist/bot/index.js +104 -197
- package/dist/bot/utils/file-download.js +75 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +58 -0
- package/dist/config.js +19 -0
- package/dist/i18n/en.js +12 -0
- package/dist/i18n/ru.js +12 -0
- package/dist/interaction/guard.js +8 -1
- package/dist/model/capabilities.js +62 -0
- package/dist/stt/client.js +64 -0
- package/dist/summary/aggregator.js +39 -11
- package/dist/summary/formatter.js +112 -0
- package/package.json +3 -2
package/dist/bot/index.js
CHANGED
|
@@ -33,23 +33,22 @@ import { questionManager } from "../question/manager.js";
|
|
|
33
33
|
import { interactionManager } from "../interaction/manager.js";
|
|
34
34
|
import { clearAllInteractionState } from "../interaction/cleanup.js";
|
|
35
35
|
import { keyboardManager } from "../keyboard/manager.js";
|
|
36
|
-
import {
|
|
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
|
-
import {
|
|
41
|
-
import { clearSession, getCurrentSession, setCurrentSession } from "../session/manager.js";
|
|
40
|
+
import { getCurrentSession } from "../session/manager.js";
|
|
42
41
|
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
|
|
43
|
-
import { getCurrentProject } from "../settings/manager.js";
|
|
44
|
-
import { getStoredAgent } from "../agent/manager.js";
|
|
45
|
-
import { getStoredModel } from "../model/manager.js";
|
|
46
|
-
import { formatVariantForButton } from "../variant/manager.js";
|
|
47
|
-
import { createMainKeyboard } from "./utils/keyboard.js";
|
|
48
42
|
import { logger } from "../utils/logger.js";
|
|
49
43
|
import { safeBackgroundTask } from "../utils/safe-background-task.js";
|
|
50
|
-
import { formatErrorDetails } from "../utils/error-format.js";
|
|
51
44
|
import { pinnedMessageManager } from "../pinned/manager.js";
|
|
52
45
|
import { t } from "../i18n/index.js";
|
|
46
|
+
import { processUserPrompt } from "./handlers/prompt.js";
|
|
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";
|
|
53
52
|
let botInstance = null;
|
|
54
53
|
let chatIdInstance = null;
|
|
55
54
|
let commandsInitialized = false;
|
|
@@ -150,24 +149,19 @@ async function ensureEventSubscription(directory) {
|
|
|
150
149
|
await toolMessageBatcher.flushSession(sessionId, "assistant_message_completed");
|
|
151
150
|
try {
|
|
152
151
|
const parts = formatSummary(messageText);
|
|
152
|
+
const assistantParseMode = getAssistantParseMode();
|
|
153
153
|
logger.debug(`[Bot] Sending completed message to Telegram (chatId=${chatIdInstance}, parts=${parts.length})`);
|
|
154
154
|
for (let i = 0; i < parts.length; i++) {
|
|
155
155
|
const isLastPart = i === parts.length - 1;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
await botInstance.api.sendMessage(chatIdInstance, parts[i]);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
await botInstance.api.sendMessage(chatIdInstance, parts[i]);
|
|
170
|
-
}
|
|
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
|
+
});
|
|
171
165
|
}
|
|
172
166
|
}
|
|
173
167
|
catch (err) {
|
|
@@ -310,6 +304,25 @@ async function ensureEventSubscription(directory) {
|
|
|
310
304
|
logger.error("[Bot] Error reloading context after compaction:", err);
|
|
311
305
|
}
|
|
312
306
|
});
|
|
307
|
+
summaryAggregator.setOnSessionError(async (sessionId, message) => {
|
|
308
|
+
if (!botInstance || !chatIdInstance) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const currentSession = getCurrentSession();
|
|
312
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
await toolMessageBatcher.flushSession(sessionId, "session_error");
|
|
316
|
+
const normalizedMessage = message.trim() || t("common.unknown_error");
|
|
317
|
+
const truncatedMessage = normalizedMessage.length > 3500
|
|
318
|
+
? `${normalizedMessage.slice(0, 3497)}...`
|
|
319
|
+
: normalizedMessage;
|
|
320
|
+
await botInstance.api
|
|
321
|
+
.sendMessage(chatIdInstance, t("bot.session_error", { message: truncatedMessage }))
|
|
322
|
+
.catch((err) => {
|
|
323
|
+
logger.error("[Bot] Failed to send session.error message:", err);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
313
326
|
summaryAggregator.setOnSessionDiff(async (_sessionId, diffs) => {
|
|
314
327
|
if (!pinnedMessageManager.isInitialized()) {
|
|
315
328
|
return;
|
|
@@ -353,41 +366,6 @@ async function ensureEventSubscription(directory) {
|
|
|
353
366
|
logger.error("Failed to subscribe to events:", err);
|
|
354
367
|
});
|
|
355
368
|
}
|
|
356
|
-
async function isSessionBusy(sessionId, directory) {
|
|
357
|
-
try {
|
|
358
|
-
const { data, error } = await opencodeClient.session.status({ directory });
|
|
359
|
-
if (error || !data) {
|
|
360
|
-
logger.warn("[Bot] Failed to check session status before prompt:", error);
|
|
361
|
-
return false;
|
|
362
|
-
}
|
|
363
|
-
const sessionStatus = data[sessionId];
|
|
364
|
-
if (!sessionStatus) {
|
|
365
|
-
return false;
|
|
366
|
-
}
|
|
367
|
-
logger.debug(`[Bot] Current session status before prompt: ${sessionStatus.type || "unknown"}`);
|
|
368
|
-
return sessionStatus.type === "busy";
|
|
369
|
-
}
|
|
370
|
-
catch (err) {
|
|
371
|
-
logger.warn("[Bot] Error checking session status before prompt:", err);
|
|
372
|
-
return false;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
async function resetMismatchedSessionContext() {
|
|
376
|
-
stopEventListening();
|
|
377
|
-
summaryAggregator.clear();
|
|
378
|
-
clearAllInteractionState("session_mismatch_reset");
|
|
379
|
-
clearSession();
|
|
380
|
-
keyboardManager.clearContext();
|
|
381
|
-
if (!pinnedMessageManager.isInitialized()) {
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
try {
|
|
385
|
-
await pinnedMessageManager.clear();
|
|
386
|
-
}
|
|
387
|
-
catch (err) {
|
|
388
|
-
logger.error("[Bot] Failed to clear pinned message during session reset:", err);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
369
|
export function createBot() {
|
|
392
370
|
clearAllInteractionState("bot_startup");
|
|
393
371
|
toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
|
|
@@ -591,6 +569,70 @@ export function createBot() {
|
|
|
591
569
|
logger.warn("[Bot] Could not clear global commands:", result.error);
|
|
592
570
|
},
|
|
593
571
|
});
|
|
572
|
+
// Voice and audio message handlers (STT transcription -> prompt)
|
|
573
|
+
const voicePromptDeps = { bot, ensureEventSubscription };
|
|
574
|
+
bot.on("message:voice", async (ctx) => {
|
|
575
|
+
logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
|
|
576
|
+
botInstance = bot;
|
|
577
|
+
chatIdInstance = ctx.chat.id;
|
|
578
|
+
await handleVoiceMessage(ctx, voicePromptDeps);
|
|
579
|
+
});
|
|
580
|
+
bot.on("message:audio", async (ctx) => {
|
|
581
|
+
logger.debug(`[Bot] Received audio message, chatId=${ctx.chat.id}`);
|
|
582
|
+
botInstance = bot;
|
|
583
|
+
chatIdInstance = ctx.chat.id;
|
|
584
|
+
await handleVoiceMessage(ctx, voicePromptDeps);
|
|
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
|
+
});
|
|
594
636
|
bot.on("message:text", async (ctx) => {
|
|
595
637
|
const text = ctx.message?.text;
|
|
596
638
|
if (!text) {
|
|
@@ -607,145 +649,10 @@ export function createBot() {
|
|
|
607
649
|
if (handledRename) {
|
|
608
650
|
return;
|
|
609
651
|
}
|
|
610
|
-
const currentProject = getCurrentProject();
|
|
611
|
-
if (!currentProject) {
|
|
612
|
-
await ctx.reply(t("bot.project_not_selected"));
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
652
|
botInstance = bot;
|
|
616
653
|
chatIdInstance = ctx.chat.id;
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
pinnedMessageManager.initialize(bot.api, ctx.chat.id);
|
|
620
|
-
}
|
|
621
|
-
// Initialize keyboard manager if not already
|
|
622
|
-
keyboardManager.initialize(bot.api, ctx.chat.id);
|
|
623
|
-
let currentSession = getCurrentSession();
|
|
624
|
-
if (currentSession && currentSession.directory !== currentProject.worktree) {
|
|
625
|
-
logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
|
|
626
|
-
await resetMismatchedSessionContext();
|
|
627
|
-
await ctx.reply(t("bot.session_reset_project_mismatch"));
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
if (!currentSession) {
|
|
631
|
-
await ctx.reply(t("bot.creating_session"));
|
|
632
|
-
const { data: session, error } = await opencodeClient.session.create({
|
|
633
|
-
directory: currentProject.worktree,
|
|
634
|
-
});
|
|
635
|
-
if (error || !session) {
|
|
636
|
-
await ctx.reply(t("bot.create_session_error"));
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
logger.info(`[Bot] Created new session: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
|
|
640
|
-
currentSession = {
|
|
641
|
-
id: session.id,
|
|
642
|
-
title: session.title,
|
|
643
|
-
directory: currentProject.worktree,
|
|
644
|
-
};
|
|
645
|
-
setCurrentSession(currentSession);
|
|
646
|
-
await ingestSessionInfoForCache(session);
|
|
647
|
-
// Create pinned message for new session
|
|
648
|
-
try {
|
|
649
|
-
await pinnedMessageManager.onSessionChange(session.id, session.title);
|
|
650
|
-
}
|
|
651
|
-
catch (err) {
|
|
652
|
-
logger.error("[Bot] Error creating pinned message for new session:", err);
|
|
653
|
-
}
|
|
654
|
-
const currentAgent = getStoredAgent();
|
|
655
|
-
const currentModel = getStoredModel();
|
|
656
|
-
const contextInfo = pinnedMessageManager.getContextInfo();
|
|
657
|
-
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
658
|
-
const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
|
|
659
|
-
await ctx.reply(t("bot.session_created", { title: session.title }), {
|
|
660
|
-
reply_markup: keyboard,
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
else {
|
|
664
|
-
logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
|
|
665
|
-
// Ensure pinned message exists for existing session
|
|
666
|
-
if (!pinnedMessageManager.getState().messageId) {
|
|
667
|
-
try {
|
|
668
|
-
await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title);
|
|
669
|
-
}
|
|
670
|
-
catch (err) {
|
|
671
|
-
logger.error("[Bot] Error creating pinned message for existing session:", err);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
await ensureEventSubscription(currentSession.directory);
|
|
676
|
-
summaryAggregator.setSession(currentSession.id);
|
|
677
|
-
summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
|
|
678
|
-
const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
|
|
679
|
-
if (sessionIsBusy) {
|
|
680
|
-
logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
|
|
681
|
-
await ctx.reply(t("bot.session_busy"));
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
try {
|
|
685
|
-
const currentAgent = getStoredAgent();
|
|
686
|
-
const storedModel = getStoredModel();
|
|
687
|
-
const promptOptions = {
|
|
688
|
-
sessionID: currentSession.id,
|
|
689
|
-
directory: currentSession.directory,
|
|
690
|
-
parts: [{ type: "text", text }],
|
|
691
|
-
agent: currentAgent,
|
|
692
|
-
};
|
|
693
|
-
// Use stored model (from settings or config)
|
|
694
|
-
if (storedModel.providerID && storedModel.modelID) {
|
|
695
|
-
promptOptions.model = {
|
|
696
|
-
providerID: storedModel.providerID,
|
|
697
|
-
modelID: storedModel.modelID,
|
|
698
|
-
};
|
|
699
|
-
// Add variant if specified
|
|
700
|
-
if (storedModel.variant) {
|
|
701
|
-
promptOptions.variant = storedModel.variant;
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
const promptErrorLogContext = {
|
|
705
|
-
sessionId: currentSession.id,
|
|
706
|
-
directory: currentSession.directory,
|
|
707
|
-
agent: currentAgent || "default",
|
|
708
|
-
modelProvider: storedModel.providerID || "default",
|
|
709
|
-
modelId: storedModel.modelID || "default",
|
|
710
|
-
variant: storedModel.variant || "default",
|
|
711
|
-
promptLength: text.length,
|
|
712
|
-
};
|
|
713
|
-
logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}...`);
|
|
714
|
-
// CRITICAL: DO NOT wait for session.prompt to complete.
|
|
715
|
-
// If we wait, the handler will not finish and grammY will not call getUpdates,
|
|
716
|
-
// which blocks receiving button callback_query updates.
|
|
717
|
-
// The processing result will arrive via SSE events.
|
|
718
|
-
safeBackgroundTask({
|
|
719
|
-
taskName: "session.prompt",
|
|
720
|
-
task: () => opencodeClient.session.prompt(promptOptions),
|
|
721
|
-
onSuccess: ({ error }) => {
|
|
722
|
-
if (error) {
|
|
723
|
-
const details = formatErrorDetails(error, 6000);
|
|
724
|
-
logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
|
|
725
|
-
logger.error("[Bot] session.prompt error details:", details);
|
|
726
|
-
logger.error("[Bot] session.prompt raw API error object:", error);
|
|
727
|
-
// Send user-friendly error via API directly because ctx is no longer available
|
|
728
|
-
void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
logger.info("[Bot] session.prompt completed");
|
|
732
|
-
},
|
|
733
|
-
onError: (error) => {
|
|
734
|
-
const details = formatErrorDetails(error, 6000);
|
|
735
|
-
logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
|
|
736
|
-
logger.error("[Bot] session.prompt background failure details:", details);
|
|
737
|
-
logger.error("[Bot] session.prompt raw background error object:", error);
|
|
738
|
-
void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
|
|
739
|
-
},
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
catch (err) {
|
|
743
|
-
logger.error("Error in prompt handler:", err);
|
|
744
|
-
if (interactionManager.getSnapshot()) {
|
|
745
|
-
clearAllInteractionState("message_handler_error");
|
|
746
|
-
}
|
|
747
|
-
await ctx.reply(t("error.generic"));
|
|
748
|
-
}
|
|
654
|
+
const promptDeps = { bot, ensureEventSubscription };
|
|
655
|
+
await processUserPrompt(ctx, text, promptDeps);
|
|
749
656
|
logger.debug("[Bot] message:text handler completed (prompt sent in background)");
|
|
750
657
|
});
|
|
751
658
|
bot.catch((err) => {
|
|
@@ -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"),
|
|
@@ -82,12 +93,20 @@ export const config = {
|
|
|
82
93
|
},
|
|
83
94
|
bot: {
|
|
84
95
|
sessionsListLimit: getOptionalPositiveIntEnvVar("SESSIONS_LIST_LIMIT", 10),
|
|
96
|
+
projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
|
|
85
97
|
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
|
|
86
98
|
serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
|
|
87
99
|
hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
|
|
88
100
|
hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
|
|
101
|
+
messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"),
|
|
89
102
|
},
|
|
90
103
|
files: {
|
|
91
104
|
maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),
|
|
92
105
|
},
|
|
106
|
+
stt: {
|
|
107
|
+
apiUrl: getEnvVar("STT_API_URL", false),
|
|
108
|
+
apiKey: getEnvVar("STT_API_KEY", false),
|
|
109
|
+
model: getEnvVar("STT_MODEL", false) || "whisper-large-v3-turbo",
|
|
110
|
+
language: getEnvVar("STT_LANGUAGE", false),
|
|
111
|
+
},
|
|
93
112
|
};
|
package/dist/i18n/en.js
CHANGED
|
@@ -41,7 +41,13 @@ export const en = {
|
|
|
41
41
|
"bot.session_busy": "⏳ Agent is already running a task. Wait for completion or use /stop to interrupt current run.",
|
|
42
42
|
"bot.session_reset_project_mismatch": "⚠️ Active session does not match the selected project, so it was reset. Use /sessions to pick one or /new to create a new session.",
|
|
43
43
|
"bot.prompt_send_error": "Failed to send request to OpenCode.",
|
|
44
|
+
"bot.session_error": "🔴 OpenCode returned an error: {message}",
|
|
44
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.",
|
|
45
51
|
"status.header_running": "🟢 **OpenCode Server is running**",
|
|
46
52
|
"status.health.healthy": "Healthy",
|
|
47
53
|
"status.health.unhealthy": "Unhealthy",
|
|
@@ -114,6 +120,7 @@ export const en = {
|
|
|
114
120
|
"model.change_error_callback": "Failed to change model",
|
|
115
121
|
"model.menu.empty": "⚠️ No available models",
|
|
116
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.",
|
|
117
124
|
"model.menu.error": "🔴 Failed to get models list",
|
|
118
125
|
"variant.model_not_selected_callback": "Error: model is not selected",
|
|
119
126
|
"variant.changed_callback": "Variant changed: {name}",
|
|
@@ -229,4 +236,9 @@ export const en = {
|
|
|
229
236
|
"legacy.models.no_provider_models": " ⚠️ No available models\n",
|
|
230
237
|
"legacy.models.env_hint": "💡 To use model in .env:\n",
|
|
231
238
|
"legacy.models.error": "🔴 An error occurred while loading models list.",
|
|
239
|
+
"stt.recognizing": "🎤 Recognizing audio...",
|
|
240
|
+
"stt.recognized": "🎤 Recognized:\n{text}",
|
|
241
|
+
"stt.not_configured": "🎤 Voice recognition is not configured.\n\nSet STT_API_URL and STT_API_KEY in .env to enable it.",
|
|
242
|
+
"stt.error": "🔴 Failed to recognize audio: {error}",
|
|
243
|
+
"stt.empty_result": "🎤 No speech detected in the audio message.",
|
|
232
244
|
};
|
package/dist/i18n/ru.js
CHANGED
|
@@ -41,7 +41,13 @@ export const ru = {
|
|
|
41
41
|
"bot.session_busy": "⏳ Агент уже выполняет задачу. Дождитесь завершения или используйте /stop, чтобы прервать текущий запуск.",
|
|
42
42
|
"bot.session_reset_project_mismatch": "⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.",
|
|
43
43
|
"bot.prompt_send_error": "Не удалось отправить запрос в OpenCode.",
|
|
44
|
+
"bot.session_error": "🔴 OpenCode вернул ошибку: {message}",
|
|
44
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": "💡 Совет: Добавьте подпись, чтобы описать, что делать с этим фото.",
|
|
45
51
|
"status.header_running": "🟢 **OpenCode Server запущен**",
|
|
46
52
|
"status.health.healthy": "Healthy",
|
|
47
53
|
"status.health.unhealthy": "Unhealthy",
|
|
@@ -114,6 +120,7 @@ export const ru = {
|
|
|
114
120
|
"model.change_error_callback": "Ошибка при смене модели",
|
|
115
121
|
"model.menu.empty": "⚠️ Нет доступных моделей",
|
|
116
122
|
"model.menu.current": "Текущая модель: {name}\n\nВыберите модель:",
|
|
123
|
+
"model.menu.favorites_hint": "ℹ️ Список моделей формируется из favorites в OpenCode CLI.",
|
|
117
124
|
"model.menu.error": "🔴 Не удалось получить список моделей",
|
|
118
125
|
"variant.model_not_selected_callback": "Ошибка: модель не выбрана",
|
|
119
126
|
"variant.changed_callback": "Variant изменен: {name}",
|
|
@@ -229,4 +236,9 @@ export const ru = {
|
|
|
229
236
|
"legacy.models.no_provider_models": " ⚠️ Нет доступных моделей\n",
|
|
230
237
|
"legacy.models.env_hint": "💡 Для использования модели в .env:\n",
|
|
231
238
|
"legacy.models.error": "🔴 Произошла ошибка при получении списка моделей.",
|
|
239
|
+
"stt.recognizing": "🎤 Распознаю аудио...",
|
|
240
|
+
"stt.recognized": "🎤 Распознано:\n{text}",
|
|
241
|
+
"stt.not_configured": "🎤 Распознавание голоса не настроено.\n\nУстановите STT_API_URL и STT_API_KEY в .env для включения.",
|
|
242
|
+
"stt.error": "🔴 Не удалось распознать аудио: {error}",
|
|
243
|
+
"stt.empty_result": "🎤 В аудиосообщении не обнаружена речь.",
|
|
232
244
|
};
|
|
@@ -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) {
|
|
@@ -75,7 +79,10 @@ export function resolveInteractionGuardDecision(ctx) {
|
|
|
75
79
|
return createBlockDecision(inputType, state, "command_not_allowed", command);
|
|
76
80
|
}
|
|
77
81
|
if (state.expectedInput === "mixed") {
|
|
78
|
-
|
|
82
|
+
if (inputType === "callback" || inputType === "text") {
|
|
83
|
+
return createAllowDecision(inputType, state, command);
|
|
84
|
+
}
|
|
85
|
+
return createBlockDecision(inputType, state, "expected_text", command);
|
|
79
86
|
}
|
|
80
87
|
if (inputType === "callback" && isAllowedRenameCancelCallback(ctx, state)) {
|
|
81
88
|
return createAllowDecision(inputType, state, command);
|