@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/.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
|
|
|
@@ -50,6 +54,20 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
50
54
|
# Hide tool call service messages (default: false)
|
|
51
55
|
# HIDE_TOOL_CALL_MESSAGES=false
|
|
52
56
|
|
|
57
|
+
# Assistant message formatting mode (default: markdown)
|
|
58
|
+
# markdown = convert assistant replies to Telegram MarkdownV2
|
|
59
|
+
# raw = show assistant replies as plain text
|
|
60
|
+
# MESSAGE_FORMAT_MODE=markdown
|
|
61
|
+
|
|
53
62
|
# Code File Settings (optional)
|
|
54
63
|
# Maximum file size in KB to send as document (default: 100)
|
|
55
64
|
# CODE_FILE_MAX_SIZE_KB=100
|
|
65
|
+
|
|
66
|
+
# Speech-to-Text / Voice Recognition (optional)
|
|
67
|
+
# Enable voice message transcription by setting a Whisper-compatible API URL.
|
|
68
|
+
# Works with OpenAI, Groq, or any Whisper-compatible endpoint.
|
|
69
|
+
# If STT_API_URL is not set, voice messages will get a "not configured" reply.
|
|
70
|
+
# STT_API_URL=
|
|
71
|
+
# STT_API_KEY=
|
|
72
|
+
# STT_MODEL=
|
|
73
|
+
# STT_LANGUAGE=
|
package/README.md
CHANGED
|
@@ -25,7 +25,10 @@ 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
|
|
31
|
+
- **Configurable reply formatting** — assistant replies use Telegram MarkdownV2 by default, with optional raw mode (`MESSAGE_FORMAT_MODE=markdown|raw`)
|
|
29
32
|
- **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
|
|
30
33
|
- **Localization** — English and Russian UI (`BOT_LOCALE=en|ru`)
|
|
31
34
|
|
|
@@ -102,15 +105,7 @@ opencode-telegram config
|
|
|
102
105
|
| `/opencode_stop` | Stop the OpenCode server remotely |
|
|
103
106
|
| `/help` | Show available commands |
|
|
104
107
|
|
|
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)
|
|
108
|
+
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
109
|
|
|
115
110
|
> `/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
111
|
|
|
@@ -124,26 +119,55 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
124
119
|
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
|
|
125
120
|
- **Linux:** `~/.config/opencode-telegram-bot/.env`
|
|
126
121
|
|
|
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
|
-
| `
|
|
122
|
+
| Variable | Description | Required | Default |
|
|
123
|
+
| ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ------------------------ |
|
|
124
|
+
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
|
|
125
|
+
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
|
|
126
|
+
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
|
|
127
|
+
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
|
|
128
|
+
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
|
|
129
|
+
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
|
|
130
|
+
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
|
|
131
|
+
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
|
|
132
|
+
| `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
|
|
133
|
+
| `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
|
|
134
|
+
| `PROJECTS_LIST_LIMIT` | Max projects shown in `/projects` | No | `10` |
|
|
135
|
+
| `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
|
|
136
|
+
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
137
|
+
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
138
|
+
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
|
|
139
|
+
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
|
|
140
|
+
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
|
|
141
|
+
| `STT_API_KEY` | API key for your STT provider | No | — |
|
|
142
|
+
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
|
|
143
|
+
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
|
|
144
|
+
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
144
145
|
|
|
145
146
|
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
|
|
146
147
|
|
|
148
|
+
### Voice and Audio Transcription (Optional)
|
|
149
|
+
|
|
150
|
+
If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
|
|
151
|
+
|
|
152
|
+
1. Accept `voice` and `audio` Telegram messages
|
|
153
|
+
2. Transcribe them via `POST {STT_API_URL}/audio/transcriptions`
|
|
154
|
+
3. Show recognized text in chat
|
|
155
|
+
4. Send the recognized text to OpenCode as a normal prompt
|
|
156
|
+
|
|
157
|
+
Supported provider examples (Whisper-compatible):
|
|
158
|
+
|
|
159
|
+
- **OpenAI**
|
|
160
|
+
- `STT_API_URL=https://api.openai.com/v1`
|
|
161
|
+
- `STT_MODEL=whisper-1`
|
|
162
|
+
- **Groq**
|
|
163
|
+
- `STT_API_URL=https://api.groq.com/openai/v1`
|
|
164
|
+
- `STT_MODEL=whisper-large-v3-turbo`
|
|
165
|
+
- **Together**
|
|
166
|
+
- `STT_API_URL=https://api.together.xyz/v1`
|
|
167
|
+
- `STT_MODEL=openai/whisper-large-v3`
|
|
168
|
+
|
|
169
|
+
If STT variables are not set, voice/audio transcription is disabled and the bot will ask you to configure STT.
|
|
170
|
+
|
|
147
171
|
### Model Configuration
|
|
148
172
|
|
|
149
173
|
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;
|
|
@@ -7,8 +7,20 @@ import { createMainKeyboard } from "../utils/keyboard.js";
|
|
|
7
7
|
import { getStoredAgent } from "../../agent/manager.js";
|
|
8
8
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
9
9
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
10
|
+
import { config } from "../../config.js";
|
|
10
11
|
import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
|
|
11
12
|
import { t } from "../../i18n/index.js";
|
|
13
|
+
function isOnlyConfigDefaultModel(models) {
|
|
14
|
+
if (models.length !== 1) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const defaultProviderID = config.opencode.model.provider;
|
|
18
|
+
const defaultModelID = config.opencode.model.modelId;
|
|
19
|
+
if (!defaultProviderID || !defaultModelID) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return models[0].providerID === defaultProviderID && models[0].modelID === defaultModelID;
|
|
23
|
+
}
|
|
12
24
|
/**
|
|
13
25
|
* Handle model selection callback
|
|
14
26
|
* @param ctx grammY context
|
|
@@ -83,9 +95,9 @@ export async function handleModelSelect(ctx) {
|
|
|
83
95
|
* @param currentModel Current model for highlighting
|
|
84
96
|
* @returns InlineKeyboard with model selection buttons
|
|
85
97
|
*/
|
|
86
|
-
export async function buildModelSelectionMenu(currentModel) {
|
|
98
|
+
export async function buildModelSelectionMenu(currentModel, favoriteModels) {
|
|
87
99
|
const keyboard = new InlineKeyboard();
|
|
88
|
-
const favorites = await getFavoriteModels();
|
|
100
|
+
const favorites = favoriteModels ?? (await getFavoriteModels());
|
|
89
101
|
if (favorites.length === 0) {
|
|
90
102
|
logger.warn("[ModelHandler] No favorite models found");
|
|
91
103
|
return keyboard;
|
|
@@ -109,7 +121,8 @@ export async function buildModelSelectionMenu(currentModel) {
|
|
|
109
121
|
export async function showModelSelectionMenu(ctx) {
|
|
110
122
|
try {
|
|
111
123
|
const currentModel = fetchCurrentModel();
|
|
112
|
-
const
|
|
124
|
+
const favorites = await getFavoriteModels();
|
|
125
|
+
const keyboard = await buildModelSelectionMenu(currentModel, favorites);
|
|
113
126
|
if (keyboard.inline_keyboard.length === 0) {
|
|
114
127
|
await ctx.reply(t("model.menu.empty"));
|
|
115
128
|
return;
|
|
@@ -121,6 +134,9 @@ export async function showModelSelectionMenu(ctx) {
|
|
|
121
134
|
text,
|
|
122
135
|
keyboard,
|
|
123
136
|
});
|
|
137
|
+
if (isOnlyConfigDefaultModel(favorites)) {
|
|
138
|
+
await ctx.reply(t("model.menu.favorites_hint"));
|
|
139
|
+
}
|
|
124
140
|
}
|
|
125
141
|
catch (err) {
|
|
126
142
|
logger.error("[ModelHandler] Error showing model menu:", err);
|
|
@@ -0,0 +1,232 @@
|
|
|
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 text, voice, and photo message handlers.
|
|
66
|
+
*
|
|
67
|
+
* @param ctx - Grammy context
|
|
68
|
+
* @param text - Text content of the prompt
|
|
69
|
+
* @param deps - Dependencies (bot and event subscription)
|
|
70
|
+
* @param fileParts - Optional file parts (for photo/document attachments)
|
|
71
|
+
* @returns true if the prompt was dispatched, false if it was blocked/failed early.
|
|
72
|
+
*/
|
|
73
|
+
export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
74
|
+
const { bot, ensureEventSubscription } = deps;
|
|
75
|
+
const currentProject = getCurrentProject();
|
|
76
|
+
if (!currentProject) {
|
|
77
|
+
await ctx.reply(t("bot.project_not_selected"));
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
botInstance = bot;
|
|
81
|
+
chatIdInstance = ctx.chat.id;
|
|
82
|
+
// Initialize pinned message manager if not already
|
|
83
|
+
if (!pinnedMessageManager.isInitialized()) {
|
|
84
|
+
pinnedMessageManager.initialize(bot.api, ctx.chat.id);
|
|
85
|
+
}
|
|
86
|
+
// Initialize keyboard manager if not already
|
|
87
|
+
keyboardManager.initialize(bot.api, ctx.chat.id);
|
|
88
|
+
let currentSession = getCurrentSession();
|
|
89
|
+
if (currentSession && currentSession.directory !== currentProject.worktree) {
|
|
90
|
+
logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
|
|
91
|
+
await resetMismatchedSessionContext();
|
|
92
|
+
await ctx.reply(t("bot.session_reset_project_mismatch"));
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (!currentSession) {
|
|
96
|
+
await ctx.reply(t("bot.creating_session"));
|
|
97
|
+
const { data: session, error } = await opencodeClient.session.create({
|
|
98
|
+
directory: currentProject.worktree,
|
|
99
|
+
});
|
|
100
|
+
if (error || !session) {
|
|
101
|
+
await ctx.reply(t("bot.create_session_error"));
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
logger.info(`[Bot] Created new session: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
|
|
105
|
+
currentSession = {
|
|
106
|
+
id: session.id,
|
|
107
|
+
title: session.title,
|
|
108
|
+
directory: currentProject.worktree,
|
|
109
|
+
};
|
|
110
|
+
setCurrentSession(currentSession);
|
|
111
|
+
await ingestSessionInfoForCache(session);
|
|
112
|
+
// Create pinned message for new session
|
|
113
|
+
try {
|
|
114
|
+
await pinnedMessageManager.onSessionChange(session.id, session.title);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
logger.error("[Bot] Error creating pinned message for new session:", err);
|
|
118
|
+
}
|
|
119
|
+
const currentAgent = getStoredAgent();
|
|
120
|
+
const currentModel = getStoredModel();
|
|
121
|
+
const contextInfo = pinnedMessageManager.getContextInfo();
|
|
122
|
+
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
123
|
+
const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
|
|
124
|
+
await ctx.reply(t("bot.session_created", { title: session.title }), {
|
|
125
|
+
reply_markup: keyboard,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
|
|
130
|
+
// Ensure pinned message exists for existing session
|
|
131
|
+
if (!pinnedMessageManager.getState().messageId) {
|
|
132
|
+
try {
|
|
133
|
+
await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
logger.error("[Bot] Error creating pinned message for existing session:", err);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
await ensureEventSubscription(currentSession.directory);
|
|
141
|
+
summaryAggregator.setSession(currentSession.id);
|
|
142
|
+
summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
|
|
143
|
+
const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
|
|
144
|
+
if (sessionIsBusy) {
|
|
145
|
+
logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
|
|
146
|
+
await ctx.reply(t("bot.session_busy"));
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const currentAgent = getStoredAgent();
|
|
151
|
+
const storedModel = getStoredModel();
|
|
152
|
+
// Build parts array with text and files
|
|
153
|
+
const parts = [];
|
|
154
|
+
// Add text part if present
|
|
155
|
+
if (text.trim().length > 0) {
|
|
156
|
+
parts.push({ type: "text", text });
|
|
157
|
+
}
|
|
158
|
+
// Add file parts
|
|
159
|
+
parts.push(...fileParts);
|
|
160
|
+
// If no text and files exist, use a placeholder
|
|
161
|
+
if (parts.length === 0 || (parts.length > 0 && parts.every((p) => p.type === "file"))) {
|
|
162
|
+
if (fileParts.length > 0) {
|
|
163
|
+
// Files without text - add a minimal system prompt
|
|
164
|
+
parts.unshift({ type: "text", text: "See attached file" });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const promptOptions = {
|
|
168
|
+
sessionID: currentSession.id,
|
|
169
|
+
directory: currentSession.directory,
|
|
170
|
+
parts,
|
|
171
|
+
agent: currentAgent,
|
|
172
|
+
};
|
|
173
|
+
// Use stored model (from settings or config)
|
|
174
|
+
if (storedModel.providerID && storedModel.modelID) {
|
|
175
|
+
promptOptions.model = {
|
|
176
|
+
providerID: storedModel.providerID,
|
|
177
|
+
modelID: storedModel.modelID,
|
|
178
|
+
};
|
|
179
|
+
// Add variant if specified
|
|
180
|
+
if (storedModel.variant) {
|
|
181
|
+
promptOptions.variant = storedModel.variant;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const promptErrorLogContext = {
|
|
185
|
+
sessionId: currentSession.id,
|
|
186
|
+
directory: currentSession.directory,
|
|
187
|
+
agent: currentAgent || "default",
|
|
188
|
+
modelProvider: storedModel.providerID || "default",
|
|
189
|
+
modelId: storedModel.modelID || "default",
|
|
190
|
+
variant: storedModel.variant || "default",
|
|
191
|
+
promptLength: text.length,
|
|
192
|
+
fileCount: fileParts.length,
|
|
193
|
+
};
|
|
194
|
+
logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
|
|
195
|
+
// CRITICAL: DO NOT wait for session.prompt to complete.
|
|
196
|
+
// If we wait, the handler will not finish and grammY will not call getUpdates,
|
|
197
|
+
// which blocks receiving button callback_query updates.
|
|
198
|
+
// The processing result will arrive via SSE events.
|
|
199
|
+
safeBackgroundTask({
|
|
200
|
+
taskName: "session.prompt",
|
|
201
|
+
task: () => opencodeClient.session.prompt(promptOptions),
|
|
202
|
+
onSuccess: ({ error }) => {
|
|
203
|
+
if (error) {
|
|
204
|
+
const details = formatErrorDetails(error, 6000);
|
|
205
|
+
logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
|
|
206
|
+
logger.error("[Bot] session.prompt error details:", details);
|
|
207
|
+
logger.error("[Bot] session.prompt raw API error object:", error);
|
|
208
|
+
// Send user-friendly error via API directly because ctx is no longer available
|
|
209
|
+
void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
logger.info("[Bot] session.prompt completed");
|
|
213
|
+
},
|
|
214
|
+
onError: (error) => {
|
|
215
|
+
const details = formatErrorDetails(error, 6000);
|
|
216
|
+
logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
|
|
217
|
+
logger.error("[Bot] session.prompt background failure details:", details);
|
|
218
|
+
logger.error("[Bot] session.prompt raw background error object:", error);
|
|
219
|
+
void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
logger.error("Error in prompt handler:", err);
|
|
226
|
+
if (interactionManager.getSnapshot()) {
|
|
227
|
+
clearAllInteractionState("message_handler_error");
|
|
228
|
+
}
|
|
229
|
+
await ctx.reply(t("error.generic"));
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -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
|
+
}
|