@grinev/opencode-telegram-bot 0.6.1-rc.1 → 0.7.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 +13 -0
- package/README.md +48 -26
- package/dist/bot/commands/projects.js +2 -2
- package/dist/bot/handlers/prompt.js +212 -0
- package/dist/bot/handlers/voice.js +170 -0
- package/dist/bot/index.js +39 -171
- package/dist/config.js +7 -0
- package/dist/i18n/en.js +7 -2
- package/dist/i18n/ru.js +7 -2
- package/dist/interaction/guard.js +4 -1
- package/dist/stt/client.js +64 -0
- package/dist/summary/aggregator.js +39 -10
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -36,6 +36,10 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
36
36
|
# Bot Configuration (optional)
|
|
37
37
|
# Maximum number of sessions shown in /sessions (default: 10)
|
|
38
38
|
# SESSIONS_LIST_LIMIT=10
|
|
39
|
+
|
|
40
|
+
# Maximum number of projects shown in /projects (default: 10)
|
|
41
|
+
# PROJECTS_LIST_LIMIT=10
|
|
42
|
+
|
|
39
43
|
# Bot locale: en or ru (default: en)
|
|
40
44
|
# BOT_LOCALE=en
|
|
41
45
|
|
|
@@ -53,3 +57,12 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
53
57
|
# Code File Settings (optional)
|
|
54
58
|
# Maximum file size in KB to send as document (default: 100)
|
|
55
59
|
# CODE_FILE_MAX_SIZE_KB=100
|
|
60
|
+
|
|
61
|
+
# Speech-to-Text / Voice Recognition (optional)
|
|
62
|
+
# Enable voice message transcription by setting a Whisper-compatible API URL.
|
|
63
|
+
# Works with OpenAI, Groq, or any Whisper-compatible endpoint.
|
|
64
|
+
# If STT_API_URL is not set, voice messages will get a "not configured" reply.
|
|
65
|
+
# STT_API_URL=
|
|
66
|
+
# STT_API_KEY=
|
|
67
|
+
# STT_MODEL=
|
|
68
|
+
# STT_LANGUAGE=
|
package/README.md
CHANGED
|
@@ -25,7 +25,9 @@ Quick start: `npx @grinev/opencode-telegram-bot`
|
|
|
25
25
|
- **Model switching** — pick any model from your OpenCode favorites directly in the chat
|
|
26
26
|
- **Agent modes** — switch between Plan and Build modes on the fly
|
|
27
27
|
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
|
|
28
|
+
- **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
|
|
28
29
|
- **Context control** — compact context when it gets too large, right from the chat
|
|
30
|
+
- **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions
|
|
29
31
|
- **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
|
|
30
32
|
- **Localization** — English and Russian UI (`BOT_LOCALE=en|ru`)
|
|
31
33
|
|
|
@@ -102,15 +104,7 @@ opencode-telegram config
|
|
|
102
104
|
| `/opencode_stop` | Stop the OpenCode server remotely |
|
|
103
105
|
| `/help` | Show available commands |
|
|
104
106
|
|
|
105
|
-
Any regular text message is sent as a prompt to the coding agent only when no blocking interaction is active.
|
|
106
|
-
|
|
107
|
-
### Interaction Rules
|
|
108
|
-
|
|
109
|
-
- Only one interactive flow can be active at a time (inline menus, permission request, question flow, rename)
|
|
110
|
-
- While an interaction is active, the bot accepts only relevant input for that flow and blocks unrelated actions
|
|
111
|
-
- Allowed utility commands remain available during active interactions: `/help`, `/status`, `/stop`
|
|
112
|
-
- Unknown slash commands return an explicit fallback message instead of being silently ignored
|
|
113
|
-
- Interaction flows do not expire automatically and wait until explicit completion (`answer`, `cancel`, `/stop`, or reset/cleanup)
|
|
107
|
+
Any regular text message is sent as a prompt to the coding agent only when no blocking interaction is active. Voice/audio messages are transcribed and then sent as prompts when STT is configured.
|
|
114
108
|
|
|
115
109
|
> `/opencode_start` and `/opencode_stop` are intended as emergency commands — for example, if you need to restart a stuck server while away from your computer. Under normal usage, start `opencode serve` yourself before launching the bot.
|
|
116
110
|
|
|
@@ -124,26 +118,54 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
124
118
|
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
|
|
125
119
|
- **Linux:** `~/.config/opencode-telegram-bot/.env`
|
|
126
120
|
|
|
127
|
-
| Variable | Description | Required | Default
|
|
128
|
-
| ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: |
|
|
129
|
-
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | —
|
|
130
|
-
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | —
|
|
131
|
-
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | —
|
|
132
|
-
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096`
|
|
133
|
-
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode`
|
|
134
|
-
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | —
|
|
135
|
-
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode`
|
|
136
|
-
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle`
|
|
137
|
-
| `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en`
|
|
138
|
-
| `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10`
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
121
|
+
| Variable | Description | Required | Default |
|
|
122
|
+
| ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ------------------------ |
|
|
123
|
+
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
|
|
124
|
+
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
|
|
125
|
+
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
|
|
126
|
+
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
|
|
127
|
+
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
|
|
128
|
+
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
|
|
129
|
+
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
|
|
130
|
+
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
|
|
131
|
+
| `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
|
|
132
|
+
| `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
|
|
133
|
+
| `PROJECTS_LIST_LIMIT` | Max projects shown in `/projects` | No | `10` |
|
|
134
|
+
| `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
|
|
135
|
+
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
136
|
+
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
137
|
+
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
|
|
138
|
+
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
|
|
139
|
+
| `STT_API_KEY` | API key for your STT provider | No | — |
|
|
140
|
+
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
|
|
141
|
+
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
|
|
142
|
+
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
144
143
|
|
|
145
144
|
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
|
|
146
145
|
|
|
146
|
+
### Voice and Audio Transcription (Optional)
|
|
147
|
+
|
|
148
|
+
If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
|
|
149
|
+
|
|
150
|
+
1. Accept `voice` and `audio` Telegram messages
|
|
151
|
+
2. Transcribe them via `POST {STT_API_URL}/audio/transcriptions`
|
|
152
|
+
3. Show recognized text in chat
|
|
153
|
+
4. Send the recognized text to OpenCode as a normal prompt
|
|
154
|
+
|
|
155
|
+
Supported provider examples (Whisper-compatible):
|
|
156
|
+
|
|
157
|
+
- **OpenAI**
|
|
158
|
+
- `STT_API_URL=https://api.openai.com/v1`
|
|
159
|
+
- `STT_MODEL=whisper-1`
|
|
160
|
+
- **Groq**
|
|
161
|
+
- `STT_API_URL=https://api.groq.com/openai/v1`
|
|
162
|
+
- `STT_MODEL=whisper-large-v3-turbo`
|
|
163
|
+
- **Together**
|
|
164
|
+
- `STT_API_URL=https://api.together.xyz/v1`
|
|
165
|
+
- `STT_MODEL=openai/whisper-large-v3`
|
|
166
|
+
|
|
167
|
+
If STT variables are not set, voice/audio transcription is disabled and the bot will ask you to configure STT.
|
|
168
|
+
|
|
147
169
|
### Model Configuration
|
|
148
170
|
|
|
149
171
|
The bot picks up your **favorite models** from OpenCode. To add a model to favorites:
|
|
@@ -14,8 +14,8 @@ import { createMainKeyboard } from "../utils/keyboard.js";
|
|
|
14
14
|
import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
|
|
15
15
|
import { logger } from "../../utils/logger.js";
|
|
16
16
|
import { t } from "../../i18n/index.js";
|
|
17
|
+
import { config } from "../../config.js";
|
|
17
18
|
const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
|
|
18
|
-
const MAX_PROJECTS_TO_SHOW = 10;
|
|
19
19
|
function formatProjectButtonLabel(label, isActive) {
|
|
20
20
|
const prefix = isActive ? "✅ " : "";
|
|
21
21
|
const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
|
|
@@ -28,7 +28,7 @@ export async function projectsCommand(ctx) {
|
|
|
28
28
|
try {
|
|
29
29
|
await syncSessionDirectoryCache();
|
|
30
30
|
const projects = await getProjects();
|
|
31
|
-
const projectsToShow = projects.slice(0,
|
|
31
|
+
const projectsToShow = projects.slice(0, config.bot.projectsListLimit);
|
|
32
32
|
if (projectsToShow.length === 0) {
|
|
33
33
|
await ctx.reply(t("projects.empty"));
|
|
34
34
|
return;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
2
|
+
import { clearSession, getCurrentSession, setCurrentSession } from "../../session/manager.js";
|
|
3
|
+
import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
|
|
4
|
+
import { getCurrentProject } from "../../settings/manager.js";
|
|
5
|
+
import { getStoredAgent } from "../../agent/manager.js";
|
|
6
|
+
import { getStoredModel } from "../../model/manager.js";
|
|
7
|
+
import { formatVariantForButton } from "../../variant/manager.js";
|
|
8
|
+
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
9
|
+
import { keyboardManager } from "../../keyboard/manager.js";
|
|
10
|
+
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
11
|
+
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
12
|
+
import { stopEventListening } from "../../opencode/events.js";
|
|
13
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
14
|
+
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
15
|
+
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
16
|
+
import { formatErrorDetails } from "../../utils/error-format.js";
|
|
17
|
+
import { logger } from "../../utils/logger.js";
|
|
18
|
+
import { t } from "../../i18n/index.js";
|
|
19
|
+
/** Module-level references for async callbacks that don't have ctx. */
|
|
20
|
+
let botInstance = null;
|
|
21
|
+
let chatIdInstance = null;
|
|
22
|
+
export function getPromptBotInstance() {
|
|
23
|
+
return botInstance;
|
|
24
|
+
}
|
|
25
|
+
export function getPromptChatId() {
|
|
26
|
+
return chatIdInstance;
|
|
27
|
+
}
|
|
28
|
+
async function isSessionBusy(sessionId, directory) {
|
|
29
|
+
try {
|
|
30
|
+
const { data, error } = await opencodeClient.session.status({ directory });
|
|
31
|
+
if (error || !data) {
|
|
32
|
+
logger.warn("[Bot] Failed to check session status before prompt:", error);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const sessionStatus = data[sessionId];
|
|
36
|
+
if (!sessionStatus) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
logger.debug(`[Bot] Current session status before prompt: ${sessionStatus.type || "unknown"}`);
|
|
40
|
+
return sessionStatus.type === "busy";
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
logger.warn("[Bot] Error checking session status before prompt:", err);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function resetMismatchedSessionContext() {
|
|
48
|
+
stopEventListening();
|
|
49
|
+
summaryAggregator.clear();
|
|
50
|
+
clearAllInteractionState("session_mismatch_reset");
|
|
51
|
+
clearSession();
|
|
52
|
+
keyboardManager.clearContext();
|
|
53
|
+
if (!pinnedMessageManager.isInitialized()) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await pinnedMessageManager.clear();
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
logger.error("[Bot] Failed to clear pinned message during session reset:", err);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Processes a user prompt: ensures project/session, subscribes to events, and sends
|
|
65
|
+
* the prompt to OpenCode. Used by both text and voice message handlers.
|
|
66
|
+
*
|
|
67
|
+
* @returns true if the prompt was dispatched, false if it was blocked/failed early.
|
|
68
|
+
*/
|
|
69
|
+
export async function processUserPrompt(ctx, text, deps) {
|
|
70
|
+
const { bot, ensureEventSubscription } = deps;
|
|
71
|
+
const currentProject = getCurrentProject();
|
|
72
|
+
if (!currentProject) {
|
|
73
|
+
await ctx.reply(t("bot.project_not_selected"));
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
botInstance = bot;
|
|
77
|
+
chatIdInstance = ctx.chat.id;
|
|
78
|
+
// Initialize pinned message manager if not already
|
|
79
|
+
if (!pinnedMessageManager.isInitialized()) {
|
|
80
|
+
pinnedMessageManager.initialize(bot.api, ctx.chat.id);
|
|
81
|
+
}
|
|
82
|
+
// Initialize keyboard manager if not already
|
|
83
|
+
keyboardManager.initialize(bot.api, ctx.chat.id);
|
|
84
|
+
let currentSession = getCurrentSession();
|
|
85
|
+
if (currentSession && currentSession.directory !== currentProject.worktree) {
|
|
86
|
+
logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
|
|
87
|
+
await resetMismatchedSessionContext();
|
|
88
|
+
await ctx.reply(t("bot.session_reset_project_mismatch"));
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (!currentSession) {
|
|
92
|
+
await ctx.reply(t("bot.creating_session"));
|
|
93
|
+
const { data: session, error } = await opencodeClient.session.create({
|
|
94
|
+
directory: currentProject.worktree,
|
|
95
|
+
});
|
|
96
|
+
if (error || !session) {
|
|
97
|
+
await ctx.reply(t("bot.create_session_error"));
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
logger.info(`[Bot] Created new session: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
|
|
101
|
+
currentSession = {
|
|
102
|
+
id: session.id,
|
|
103
|
+
title: session.title,
|
|
104
|
+
directory: currentProject.worktree,
|
|
105
|
+
};
|
|
106
|
+
setCurrentSession(currentSession);
|
|
107
|
+
await ingestSessionInfoForCache(session);
|
|
108
|
+
// Create pinned message for new session
|
|
109
|
+
try {
|
|
110
|
+
await pinnedMessageManager.onSessionChange(session.id, session.title);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
logger.error("[Bot] Error creating pinned message for new session:", err);
|
|
114
|
+
}
|
|
115
|
+
const currentAgent = getStoredAgent();
|
|
116
|
+
const currentModel = getStoredModel();
|
|
117
|
+
const contextInfo = pinnedMessageManager.getContextInfo();
|
|
118
|
+
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
119
|
+
const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
|
|
120
|
+
await ctx.reply(t("bot.session_created", { title: session.title }), {
|
|
121
|
+
reply_markup: keyboard,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
|
|
126
|
+
// Ensure pinned message exists for existing session
|
|
127
|
+
if (!pinnedMessageManager.getState().messageId) {
|
|
128
|
+
try {
|
|
129
|
+
await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
logger.error("[Bot] Error creating pinned message for existing session:", err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await ensureEventSubscription(currentSession.directory);
|
|
137
|
+
summaryAggregator.setSession(currentSession.id);
|
|
138
|
+
summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
|
|
139
|
+
const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
|
|
140
|
+
if (sessionIsBusy) {
|
|
141
|
+
logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
|
|
142
|
+
await ctx.reply(t("bot.session_busy"));
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const currentAgent = getStoredAgent();
|
|
147
|
+
const storedModel = getStoredModel();
|
|
148
|
+
const promptOptions = {
|
|
149
|
+
sessionID: currentSession.id,
|
|
150
|
+
directory: currentSession.directory,
|
|
151
|
+
parts: [{ type: "text", text }],
|
|
152
|
+
agent: currentAgent,
|
|
153
|
+
};
|
|
154
|
+
// Use stored model (from settings or config)
|
|
155
|
+
if (storedModel.providerID && storedModel.modelID) {
|
|
156
|
+
promptOptions.model = {
|
|
157
|
+
providerID: storedModel.providerID,
|
|
158
|
+
modelID: storedModel.modelID,
|
|
159
|
+
};
|
|
160
|
+
// Add variant if specified
|
|
161
|
+
if (storedModel.variant) {
|
|
162
|
+
promptOptions.variant = storedModel.variant;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const promptErrorLogContext = {
|
|
166
|
+
sessionId: currentSession.id,
|
|
167
|
+
directory: currentSession.directory,
|
|
168
|
+
agent: currentAgent || "default",
|
|
169
|
+
modelProvider: storedModel.providerID || "default",
|
|
170
|
+
modelId: storedModel.modelID || "default",
|
|
171
|
+
variant: storedModel.variant || "default",
|
|
172
|
+
promptLength: text.length,
|
|
173
|
+
};
|
|
174
|
+
logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}...`);
|
|
175
|
+
// CRITICAL: DO NOT wait for session.prompt to complete.
|
|
176
|
+
// If we wait, the handler will not finish and grammY will not call getUpdates,
|
|
177
|
+
// which blocks receiving button callback_query updates.
|
|
178
|
+
// The processing result will arrive via SSE events.
|
|
179
|
+
safeBackgroundTask({
|
|
180
|
+
taskName: "session.prompt",
|
|
181
|
+
task: () => opencodeClient.session.prompt(promptOptions),
|
|
182
|
+
onSuccess: ({ error }) => {
|
|
183
|
+
if (error) {
|
|
184
|
+
const details = formatErrorDetails(error, 6000);
|
|
185
|
+
logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
|
|
186
|
+
logger.error("[Bot] session.prompt error details:", details);
|
|
187
|
+
logger.error("[Bot] session.prompt raw API error object:", error);
|
|
188
|
+
// Send user-friendly error via API directly because ctx is no longer available
|
|
189
|
+
void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
logger.info("[Bot] session.prompt completed");
|
|
193
|
+
},
|
|
194
|
+
onError: (error) => {
|
|
195
|
+
const details = formatErrorDetails(error, 6000);
|
|
196
|
+
logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
|
|
197
|
+
logger.error("[Bot] session.prompt background failure details:", details);
|
|
198
|
+
logger.error("[Bot] session.prompt raw background error object:", error);
|
|
199
|
+
void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
logger.error("Error in prompt handler:", err);
|
|
206
|
+
if (interactionManager.getSnapshot()) {
|
|
207
|
+
clearAllInteractionState("message_handler_error");
|
|
208
|
+
}
|
|
209
|
+
await ctx.reply(t("error.generic"));
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
5
|
+
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
6
|
+
import { config } from "../../config.js";
|
|
7
|
+
import { isSttConfigured, transcribeAudio } from "../../stt/client.js";
|
|
8
|
+
import { processUserPrompt } from "./prompt.js";
|
|
9
|
+
import { logger } from "../../utils/logger.js";
|
|
10
|
+
import { t } from "../../i18n/index.js";
|
|
11
|
+
const TELEGRAM_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
12
|
+
const TELEGRAM_DOWNLOAD_MAX_REDIRECTS = 3;
|
|
13
|
+
let telegramDownloadAgent;
|
|
14
|
+
function getTelegramDownloadAgent() {
|
|
15
|
+
if (telegramDownloadAgent !== undefined) {
|
|
16
|
+
return telegramDownloadAgent || undefined;
|
|
17
|
+
}
|
|
18
|
+
const proxyUrl = config.telegram.proxyUrl.trim();
|
|
19
|
+
if (!proxyUrl) {
|
|
20
|
+
telegramDownloadAgent = null;
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
telegramDownloadAgent = proxyUrl.startsWith("socks")
|
|
24
|
+
? new SocksProxyAgent(proxyUrl)
|
|
25
|
+
: new HttpsProxyAgent(proxyUrl);
|
|
26
|
+
logger.info(`[Voice] Using Telegram download proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
|
|
27
|
+
return telegramDownloadAgent;
|
|
28
|
+
}
|
|
29
|
+
async function downloadTelegramFileByUrl(url, redirectDepth = 0) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const targetUrl = new URL(url);
|
|
32
|
+
const requestModule = targetUrl.protocol === "http:" ? http : https;
|
|
33
|
+
const request = requestModule.get(targetUrl, { agent: getTelegramDownloadAgent() }, (response) => {
|
|
34
|
+
const statusCode = response.statusCode ?? 0;
|
|
35
|
+
if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
|
|
36
|
+
response.resume();
|
|
37
|
+
if (redirectDepth >= TELEGRAM_DOWNLOAD_MAX_REDIRECTS) {
|
|
38
|
+
reject(new Error("Too many redirects while downloading Telegram file"));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const redirectUrl = new URL(response.headers.location, targetUrl).toString();
|
|
42
|
+
void downloadTelegramFileByUrl(redirectUrl, redirectDepth + 1)
|
|
43
|
+
.then(resolve)
|
|
44
|
+
.catch(reject);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
48
|
+
response.resume();
|
|
49
|
+
reject(new Error(`Telegram file download failed with HTTP ${statusCode}`));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const chunks = [];
|
|
53
|
+
response.on("data", (chunk) => {
|
|
54
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
55
|
+
});
|
|
56
|
+
response.on("end", () => {
|
|
57
|
+
resolve(Buffer.concat(chunks));
|
|
58
|
+
});
|
|
59
|
+
response.on("error", reject);
|
|
60
|
+
});
|
|
61
|
+
request.on("error", reject);
|
|
62
|
+
request.setTimeout(TELEGRAM_DOWNLOAD_TIMEOUT_MS, () => {
|
|
63
|
+
request.destroy(new Error(`Telegram file download timed out after ${TELEGRAM_DOWNLOAD_TIMEOUT_MS}ms`));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Downloads the audio file from Telegram servers.
|
|
69
|
+
*
|
|
70
|
+
* @returns Buffer with file content, or null on failure
|
|
71
|
+
*/
|
|
72
|
+
async function downloadTelegramFile(ctx, fileId) {
|
|
73
|
+
try {
|
|
74
|
+
const file = await ctx.api.getFile(fileId);
|
|
75
|
+
if (!file.file_path) {
|
|
76
|
+
logger.error("[Voice] Telegram getFile returned no file_path");
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const fileUrl = `https://api.telegram.org/file/bot${ctx.api.token}/${file.file_path}`;
|
|
80
|
+
logger.debug(`[Voice] Downloading file: ${file.file_path} (${file.file_size ?? "?"} bytes)`);
|
|
81
|
+
const buffer = await downloadTelegramFileByUrl(fileUrl);
|
|
82
|
+
// Extract filename from file_path (e.g., "voice/file_123.oga" -> "file_123.oga")
|
|
83
|
+
const filename = file.file_path.split("/").pop() || "audio.ogg";
|
|
84
|
+
logger.debug(`[Voice] Downloaded file: ${filename} (${buffer.length} bytes)`);
|
|
85
|
+
return { buffer, filename };
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
logger.error("[Voice] Error downloading file from Telegram:", err);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Creates the voice message handler function.
|
|
94
|
+
*
|
|
95
|
+
* The factory pattern is used so that `bot` and `ensureEventSubscription` dependencies
|
|
96
|
+
* can be injected from createBot() without circular imports.
|
|
97
|
+
*/
|
|
98
|
+
export function createVoiceHandler(deps) {
|
|
99
|
+
return async (ctx) => {
|
|
100
|
+
await handleVoiceMessage(ctx, deps);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Handles incoming voice and audio messages:
|
|
105
|
+
* 1. Checks if STT is configured
|
|
106
|
+
* 2. Downloads the audio file from Telegram
|
|
107
|
+
* 3. Sends "recognizing..." status message
|
|
108
|
+
* 4. Calls STT API
|
|
109
|
+
* 5. Shows recognized text
|
|
110
|
+
* 6. Passes text to processUserPrompt
|
|
111
|
+
*/
|
|
112
|
+
export async function handleVoiceMessage(ctx, deps) {
|
|
113
|
+
const sttConfigured = deps.isSttConfigured ?? isSttConfigured;
|
|
114
|
+
const downloadFile = deps.downloadTelegramFile ?? downloadTelegramFile;
|
|
115
|
+
const transcribe = deps.transcribeAudio ?? transcribeAudio;
|
|
116
|
+
const processPrompt = deps.processPrompt ?? processUserPrompt;
|
|
117
|
+
// Determine file_id from voice or audio message
|
|
118
|
+
const voice = ctx.message?.voice;
|
|
119
|
+
const audio = ctx.message?.audio;
|
|
120
|
+
const fileId = voice?.file_id ?? audio?.file_id;
|
|
121
|
+
if (!fileId) {
|
|
122
|
+
logger.warn("[Voice] Received voice/audio message with no file_id");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// Check if STT is configured
|
|
126
|
+
if (!sttConfigured()) {
|
|
127
|
+
await ctx.reply(t("stt.not_configured"));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Send "recognizing..." status message (will be edited later)
|
|
131
|
+
const statusMessage = await ctx.reply(t("stt.recognizing"));
|
|
132
|
+
try {
|
|
133
|
+
// Download the audio file from Telegram
|
|
134
|
+
const fileData = await downloadFile(ctx, fileId);
|
|
135
|
+
if (!fileData) {
|
|
136
|
+
await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.error", { error: "download failed" }));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Transcribe the audio
|
|
140
|
+
const result = await transcribe(fileData.buffer, fileData.filename);
|
|
141
|
+
const recognizedText = result.text.trim();
|
|
142
|
+
if (!recognizedText) {
|
|
143
|
+
await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.empty_result"));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Show the recognized text by editing the status message.
|
|
147
|
+
// IMPORTANT: even if this edit fails (e.g. Telegram message length limits),
|
|
148
|
+
// we still send the recognized text to OpenCode as a prompt.
|
|
149
|
+
try {
|
|
150
|
+
await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.recognized", { text: recognizedText }));
|
|
151
|
+
}
|
|
152
|
+
catch (editError) {
|
|
153
|
+
logger.warn("[Voice] Failed to edit status message with recognized text:", editError);
|
|
154
|
+
}
|
|
155
|
+
logger.info(`[Voice] Transcribed audio: ${recognizedText.length} chars`);
|
|
156
|
+
// Process the recognized text as a prompt
|
|
157
|
+
await processPrompt(ctx, recognizedText, deps);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
const errorMessage = err instanceof Error ? err.message : "unknown error";
|
|
161
|
+
logger.error("[Voice] Error processing voice message:", err);
|
|
162
|
+
try {
|
|
163
|
+
await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.error", { error: errorMessage }));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// If we can't edit the status message, try sending a new one
|
|
167
|
+
await ctx.reply(t("stt.error", { error: errorMessage })).catch(() => { });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
package/dist/bot/index.js
CHANGED
|
@@ -33,23 +33,18 @@ 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
38
|
import { formatSummary, formatToolInfo } 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";
|
|
53
48
|
let botInstance = null;
|
|
54
49
|
let chatIdInstance = null;
|
|
55
50
|
let commandsInitialized = false;
|
|
@@ -310,6 +305,25 @@ async function ensureEventSubscription(directory) {
|
|
|
310
305
|
logger.error("[Bot] Error reloading context after compaction:", err);
|
|
311
306
|
}
|
|
312
307
|
});
|
|
308
|
+
summaryAggregator.setOnSessionError(async (sessionId, message) => {
|
|
309
|
+
if (!botInstance || !chatIdInstance) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const currentSession = getCurrentSession();
|
|
313
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
await toolMessageBatcher.flushSession(sessionId, "session_error");
|
|
317
|
+
const normalizedMessage = message.trim() || t("common.unknown_error");
|
|
318
|
+
const truncatedMessage = normalizedMessage.length > 3500
|
|
319
|
+
? `${normalizedMessage.slice(0, 3497)}...`
|
|
320
|
+
: normalizedMessage;
|
|
321
|
+
await botInstance.api
|
|
322
|
+
.sendMessage(chatIdInstance, t("bot.session_error", { message: truncatedMessage }))
|
|
323
|
+
.catch((err) => {
|
|
324
|
+
logger.error("[Bot] Failed to send session.error message:", err);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
313
327
|
summaryAggregator.setOnSessionDiff(async (_sessionId, diffs) => {
|
|
314
328
|
if (!pinnedMessageManager.isInitialized()) {
|
|
315
329
|
return;
|
|
@@ -353,41 +367,6 @@ async function ensureEventSubscription(directory) {
|
|
|
353
367
|
logger.error("Failed to subscribe to events:", err);
|
|
354
368
|
});
|
|
355
369
|
}
|
|
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
370
|
export function createBot() {
|
|
392
371
|
clearAllInteractionState("bot_startup");
|
|
393
372
|
toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
|
|
@@ -591,6 +570,20 @@ export function createBot() {
|
|
|
591
570
|
logger.warn("[Bot] Could not clear global commands:", result.error);
|
|
592
571
|
},
|
|
593
572
|
});
|
|
573
|
+
// Voice and audio message handlers (STT transcription -> prompt)
|
|
574
|
+
const voicePromptDeps = { bot, ensureEventSubscription };
|
|
575
|
+
bot.on("message:voice", async (ctx) => {
|
|
576
|
+
logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
|
|
577
|
+
botInstance = bot;
|
|
578
|
+
chatIdInstance = ctx.chat.id;
|
|
579
|
+
await handleVoiceMessage(ctx, voicePromptDeps);
|
|
580
|
+
});
|
|
581
|
+
bot.on("message:audio", async (ctx) => {
|
|
582
|
+
logger.debug(`[Bot] Received audio message, chatId=${ctx.chat.id}`);
|
|
583
|
+
botInstance = bot;
|
|
584
|
+
chatIdInstance = ctx.chat.id;
|
|
585
|
+
await handleVoiceMessage(ctx, voicePromptDeps);
|
|
586
|
+
});
|
|
594
587
|
bot.on("message:text", async (ctx) => {
|
|
595
588
|
const text = ctx.message?.text;
|
|
596
589
|
if (!text) {
|
|
@@ -607,135 +600,10 @@ export function createBot() {
|
|
|
607
600
|
if (handledRename) {
|
|
608
601
|
return;
|
|
609
602
|
}
|
|
610
|
-
const currentProject = getCurrentProject();
|
|
611
|
-
if (!currentProject) {
|
|
612
|
-
await ctx.reply(t("bot.project_not_selected"));
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
603
|
botInstance = bot;
|
|
616
604
|
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
|
-
logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}...`);
|
|
705
|
-
// CRITICAL: DO NOT wait for session.prompt to complete.
|
|
706
|
-
// If we wait, the handler will not finish and grammY will not call getUpdates,
|
|
707
|
-
// which blocks receiving button callback_query updates.
|
|
708
|
-
// The processing result will arrive via SSE events.
|
|
709
|
-
safeBackgroundTask({
|
|
710
|
-
taskName: "session.prompt",
|
|
711
|
-
task: () => opencodeClient.session.prompt(promptOptions),
|
|
712
|
-
onSuccess: ({ error }) => {
|
|
713
|
-
if (error) {
|
|
714
|
-
const details = formatErrorDetails(error);
|
|
715
|
-
logger.error("OpenCode API error:", error);
|
|
716
|
-
// Send the error via API directly because ctx is no longer available
|
|
717
|
-
void bot.api
|
|
718
|
-
.sendMessage(ctx.chat.id, t("bot.prompt_send_error_detailed", {
|
|
719
|
-
details,
|
|
720
|
-
}))
|
|
721
|
-
.catch(() => { });
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
logger.info("[Bot] session.prompt completed");
|
|
725
|
-
},
|
|
726
|
-
onError: (error) => {
|
|
727
|
-
logger.error("[Bot] session.prompt background task failed:", error);
|
|
728
|
-
void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
|
|
729
|
-
},
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
catch (err) {
|
|
733
|
-
logger.error("Error in prompt handler:", err);
|
|
734
|
-
if (interactionManager.getSnapshot()) {
|
|
735
|
-
clearAllInteractionState("message_handler_error");
|
|
736
|
-
}
|
|
737
|
-
await ctx.reply(t("error.generic"));
|
|
738
|
-
}
|
|
605
|
+
const promptDeps = { bot, ensureEventSubscription };
|
|
606
|
+
await processUserPrompt(ctx, text, promptDeps);
|
|
739
607
|
logger.debug("[Bot] message:text handler completed (prompt sent in background)");
|
|
740
608
|
});
|
|
741
609
|
bot.catch((err) => {
|
package/dist/config.js
CHANGED
|
@@ -82,6 +82,7 @@ export const config = {
|
|
|
82
82
|
},
|
|
83
83
|
bot: {
|
|
84
84
|
sessionsListLimit: getOptionalPositiveIntEnvVar("SESSIONS_LIST_LIMIT", 10),
|
|
85
|
+
projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
|
|
85
86
|
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
|
|
86
87
|
serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
|
|
87
88
|
hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
|
|
@@ -90,4 +91,10 @@ export const config = {
|
|
|
90
91
|
files: {
|
|
91
92
|
maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),
|
|
92
93
|
},
|
|
94
|
+
stt: {
|
|
95
|
+
apiUrl: getEnvVar("STT_API_URL", false),
|
|
96
|
+
apiKey: getEnvVar("STT_API_KEY", false),
|
|
97
|
+
model: getEnvVar("STT_MODEL", false) || "whisper-large-v3-turbo",
|
|
98
|
+
language: getEnvVar("STT_LANGUAGE", false),
|
|
99
|
+
},
|
|
93
100
|
};
|
package/dist/i18n/en.js
CHANGED
|
@@ -40,8 +40,8 @@ export const en = {
|
|
|
40
40
|
"bot.session_created": "✅ Session created: {title}",
|
|
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
|
-
"bot.
|
|
44
|
-
"bot.
|
|
43
|
+
"bot.prompt_send_error": "Failed to send request to OpenCode.",
|
|
44
|
+
"bot.session_error": "🔴 OpenCode returned an error: {message}",
|
|
45
45
|
"bot.unknown_command": "⚠️ Unknown command: {command}. Use /help to see available commands.",
|
|
46
46
|
"status.header_running": "🟢 **OpenCode Server is running**",
|
|
47
47
|
"status.health.healthy": "Healthy",
|
|
@@ -230,4 +230,9 @@ export const en = {
|
|
|
230
230
|
"legacy.models.no_provider_models": " ⚠️ No available models\n",
|
|
231
231
|
"legacy.models.env_hint": "💡 To use model in .env:\n",
|
|
232
232
|
"legacy.models.error": "🔴 An error occurred while loading models list.",
|
|
233
|
+
"stt.recognizing": "🎤 Recognizing audio...",
|
|
234
|
+
"stt.recognized": "🎤 Recognized:\n{text}",
|
|
235
|
+
"stt.not_configured": "🎤 Voice recognition is not configured.\n\nSet STT_API_URL and STT_API_KEY in .env to enable it.",
|
|
236
|
+
"stt.error": "🔴 Failed to recognize audio: {error}",
|
|
237
|
+
"stt.empty_result": "🎤 No speech detected in the audio message.",
|
|
233
238
|
};
|
package/dist/i18n/ru.js
CHANGED
|
@@ -40,8 +40,8 @@ export const ru = {
|
|
|
40
40
|
"bot.session_created": "✅ Сессия создана: {title}",
|
|
41
41
|
"bot.session_busy": "⏳ Агент уже выполняет задачу. Дождитесь завершения или используйте /stop, чтобы прервать текущий запуск.",
|
|
42
42
|
"bot.session_reset_project_mismatch": "⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.",
|
|
43
|
-
"bot.
|
|
44
|
-
"bot.
|
|
43
|
+
"bot.prompt_send_error": "Не удалось отправить запрос в OpenCode.",
|
|
44
|
+
"bot.session_error": "🔴 OpenCode вернул ошибку: {message}",
|
|
45
45
|
"bot.unknown_command": "⚠️ Неизвестная команда: {command}. Используйте /help для списка команд.",
|
|
46
46
|
"status.header_running": "🟢 **OpenCode Server запущен**",
|
|
47
47
|
"status.health.healthy": "Healthy",
|
|
@@ -230,4 +230,9 @@ export const ru = {
|
|
|
230
230
|
"legacy.models.no_provider_models": " ⚠️ Нет доступных моделей\n",
|
|
231
231
|
"legacy.models.env_hint": "💡 Для использования модели в .env:\n",
|
|
232
232
|
"legacy.models.error": "🔴 Произошла ошибка при получении списка моделей.",
|
|
233
|
+
"stt.recognizing": "🎤 Распознаю аудио...",
|
|
234
|
+
"stt.recognized": "🎤 Распознано:\n{text}",
|
|
235
|
+
"stt.not_configured": "🎤 Распознавание голоса не настроено.\n\nУстановите STT_API_URL и STT_API_KEY в .env для включения.",
|
|
236
|
+
"stt.error": "🔴 Не удалось распознать аудио: {error}",
|
|
237
|
+
"stt.empty_result": "🎤 В аудиосообщении не обнаружена речь.",
|
|
233
238
|
};
|
|
@@ -75,7 +75,10 @@ export function resolveInteractionGuardDecision(ctx) {
|
|
|
75
75
|
return createBlockDecision(inputType, state, "command_not_allowed", command);
|
|
76
76
|
}
|
|
77
77
|
if (state.expectedInput === "mixed") {
|
|
78
|
-
|
|
78
|
+
if (inputType === "callback" || inputType === "text") {
|
|
79
|
+
return createAllowDecision(inputType, state, command);
|
|
80
|
+
}
|
|
81
|
+
return createBlockDecision(inputType, state, "expected_text", command);
|
|
79
82
|
}
|
|
80
83
|
if (inputType === "callback" && isAllowedRenameCancelCallback(ctx, state)) {
|
|
81
84
|
return createAllowDecision(inputType, state, command);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { logger } from "../utils/logger.js";
|
|
3
|
+
const STT_REQUEST_TIMEOUT_MS = 60_000;
|
|
4
|
+
/**
|
|
5
|
+
* Returns true if STT is configured (API URL and API key are set).
|
|
6
|
+
*/
|
|
7
|
+
export function isSttConfigured() {
|
|
8
|
+
return Boolean(config.stt.apiUrl && config.stt.apiKey);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Transcribes an audio buffer using a Whisper-compatible API (OpenAI / Groq / etc.).
|
|
12
|
+
*
|
|
13
|
+
* Sends a multipart/form-data POST to `{STT_API_URL}/audio/transcriptions`.
|
|
14
|
+
*
|
|
15
|
+
* @param audioBuffer - Raw audio file bytes (ogg, mp3, wav, m4a, webm, etc.)
|
|
16
|
+
* @param filename - Original filename with extension (used by the API to detect format)
|
|
17
|
+
* @returns Transcribed text
|
|
18
|
+
* @throws Error if STT is not configured, the request fails, or the response is invalid
|
|
19
|
+
*/
|
|
20
|
+
export async function transcribeAudio(audioBuffer, filename) {
|
|
21
|
+
if (!isSttConfigured()) {
|
|
22
|
+
throw new Error("STT is not configured: STT_API_URL and STT_API_KEY are required");
|
|
23
|
+
}
|
|
24
|
+
const url = `${config.stt.apiUrl}/audio/transcriptions`;
|
|
25
|
+
const formData = new FormData();
|
|
26
|
+
formData.append("file", new Blob([new Uint8Array(audioBuffer)]), filename);
|
|
27
|
+
formData.append("model", config.stt.model);
|
|
28
|
+
formData.append("response_format", "json");
|
|
29
|
+
if (config.stt.language) {
|
|
30
|
+
formData.append("language", config.stt.language);
|
|
31
|
+
}
|
|
32
|
+
logger.debug(`[STT] Sending transcription request: url=${url}, model=${config.stt.model}, filename=${filename}, size=${audioBuffer.length} bytes`);
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timeout = setTimeout(() => controller.abort(), STT_REQUEST_TIMEOUT_MS);
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(url, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${config.stt.apiKey}`,
|
|
40
|
+
},
|
|
41
|
+
body: formData,
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const errorBody = await response.text().catch(() => "");
|
|
46
|
+
throw new Error(`STT API returned HTTP ${response.status}: ${errorBody || response.statusText}`);
|
|
47
|
+
}
|
|
48
|
+
const data = (await response.json());
|
|
49
|
+
if (typeof data.text !== "string") {
|
|
50
|
+
throw new Error("STT API response does not contain a text field");
|
|
51
|
+
}
|
|
52
|
+
logger.debug(`[STT] Transcription result: ${data.text.length} chars`);
|
|
53
|
+
return { text: data.text };
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
57
|
+
throw new Error(`STT request timed out after ${STT_REQUEST_TIMEOUT_MS}ms`);
|
|
58
|
+
}
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -39,11 +39,13 @@ class SummaryAggregator {
|
|
|
39
39
|
onThinkingCallback = null;
|
|
40
40
|
onTokensCallback = null;
|
|
41
41
|
onSessionCompactedCallback = null;
|
|
42
|
+
onSessionErrorCallback = null;
|
|
42
43
|
onPermissionCallback = null;
|
|
43
44
|
onSessionDiffCallback = null;
|
|
44
45
|
onFileChangeCallback = null;
|
|
45
46
|
onClearedCallback = null;
|
|
46
47
|
processedToolStates = new Set();
|
|
48
|
+
thinkingFiredForMessages = new Set();
|
|
47
49
|
bot = null;
|
|
48
50
|
chatId = null;
|
|
49
51
|
typingTimer = null;
|
|
@@ -76,6 +78,9 @@ class SummaryAggregator {
|
|
|
76
78
|
setOnSessionCompacted(callback) {
|
|
77
79
|
this.onSessionCompactedCallback = callback;
|
|
78
80
|
}
|
|
81
|
+
setOnSessionError(callback) {
|
|
82
|
+
this.onSessionErrorCallback = callback;
|
|
83
|
+
}
|
|
79
84
|
setOnPermission(callback) {
|
|
80
85
|
this.onPermissionCallback = callback;
|
|
81
86
|
}
|
|
@@ -133,6 +138,9 @@ class SummaryAggregator {
|
|
|
133
138
|
case "session.compacted":
|
|
134
139
|
this.handleSessionCompacted(event);
|
|
135
140
|
break;
|
|
141
|
+
case "session.error":
|
|
142
|
+
this.handleSessionError(event);
|
|
143
|
+
break;
|
|
136
144
|
case "question.asked":
|
|
137
145
|
this.handleQuestionAsked(event);
|
|
138
146
|
break;
|
|
@@ -170,6 +178,7 @@ class SummaryAggregator {
|
|
|
170
178
|
this.messages.clear();
|
|
171
179
|
this.partHashes.clear();
|
|
172
180
|
this.processedToolStates.clear();
|
|
181
|
+
this.thinkingFiredForMessages.clear();
|
|
173
182
|
this.messageCount = 0;
|
|
174
183
|
this.lastUpdated = 0;
|
|
175
184
|
if (this.onClearedCallback) {
|
|
@@ -193,15 +202,6 @@ class SummaryAggregator {
|
|
|
193
202
|
this.currentMessageParts.set(messageID, []);
|
|
194
203
|
this.messageCount++;
|
|
195
204
|
this.startTypingIndicator();
|
|
196
|
-
// Notify that agent started thinking
|
|
197
|
-
if (this.onThinkingCallback) {
|
|
198
|
-
const callback = this.onThinkingCallback;
|
|
199
|
-
setImmediate(() => {
|
|
200
|
-
if (typeof callback === "function") {
|
|
201
|
-
callback(info.sessionID);
|
|
202
|
-
}
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
205
|
}
|
|
206
206
|
const pending = this.pendingParts.get(messageID) || [];
|
|
207
207
|
const current = this.currentMessageParts.get(messageID) || [];
|
|
@@ -249,7 +249,21 @@ class SummaryAggregator {
|
|
|
249
249
|
}
|
|
250
250
|
const messageID = part.messageID;
|
|
251
251
|
const messageInfo = this.messages.get(messageID);
|
|
252
|
-
if (part.type === "
|
|
252
|
+
if (part.type === "reasoning") {
|
|
253
|
+
// Fire the thinking callback once per message on the first reasoning part.
|
|
254
|
+
// This is the signal that the model is actually doing extended thinking.
|
|
255
|
+
if (!this.thinkingFiredForMessages.has(messageID) && this.onThinkingCallback) {
|
|
256
|
+
this.thinkingFiredForMessages.add(messageID);
|
|
257
|
+
const callback = this.onThinkingCallback;
|
|
258
|
+
const sessionID = part.sessionID;
|
|
259
|
+
setImmediate(() => {
|
|
260
|
+
if (typeof callback === "function") {
|
|
261
|
+
callback(sessionID);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else if (part.type === "text" && "text" in part && part.text) {
|
|
253
267
|
const partHash = this.hashString(part.text);
|
|
254
268
|
if (!this.partHashes.has(messageID)) {
|
|
255
269
|
this.partHashes.set(messageID, new Set());
|
|
@@ -451,6 +465,21 @@ class SummaryAggregator {
|
|
|
451
465
|
});
|
|
452
466
|
}
|
|
453
467
|
}
|
|
468
|
+
handleSessionError(event) {
|
|
469
|
+
const { sessionID, error } = event.properties;
|
|
470
|
+
if (sessionID !== this.currentSessionId) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const message = error?.data?.message || error?.message || error?.name || "Unknown session error";
|
|
474
|
+
logger.warn(`[Aggregator] Session error: ${sessionID}: ${message}`);
|
|
475
|
+
this.stopTypingIndicator();
|
|
476
|
+
if (this.onSessionErrorCallback) {
|
|
477
|
+
const callback = this.onSessionErrorCallback;
|
|
478
|
+
setImmediate(() => {
|
|
479
|
+
callback(sessionID, message);
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
454
483
|
handleQuestionAsked(event) {
|
|
455
484
|
const { id, sessionID, questions } = event.properties;
|
|
456
485
|
if (sessionID !== this.currentSessionId) {
|