@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/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 { stopEventListening, subscribeToEvents } from "../opencode/events.js";
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 { opencodeClient } from "../opencode/client.js";
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
- if (isLastPart && keyboardManager.isInitialized()) {
157
- // Attach updated keyboard to the last message part (only if initialized)
158
- const keyboard = keyboardManager.getKeyboard();
159
- if (keyboard) {
160
- await botInstance.api.sendMessage(chatIdInstance, parts[i], {
161
- reply_markup: keyboard,
162
- });
163
- }
164
- else {
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
- // Initialize pinned message manager if not already
618
- if (!pinnedMessageManager.isInitialized()) {
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
- return createAllowDecision(inputType, state, command);
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);