@grinev/opencode-telegram-bot 0.14.0 → 0.15.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 CHANGED
@@ -46,6 +46,14 @@ OPENCODE_MODEL_ID=big-pickle
46
46
  # Maximum number of scheduled tasks allowed at once (default: 10)
47
47
  # TASK_LIMIT=10
48
48
 
49
+ # Stream update throttle in milliseconds for assistant/tool message edits (default: 500)
50
+ # Higher value = fewer Telegram edit requests, lower value = more real-time updates
51
+ # RESPONSE_STREAM_THROTTLE_MS=500
52
+
53
+ # Maximum displayed length for bash tool commands in Telegram summaries (default: 128)
54
+ # Longer commands are truncated with "..."
55
+ # BASH_TOOL_DISPLAY_MAX_LENGTH=128
56
+
49
57
  # Bot locale: supported locale code (default: en)
50
58
  # Supported locales: en, de, es, fr, ru, zh
51
59
  # BOT_LOCALE=en
@@ -73,3 +81,11 @@ OPENCODE_MODEL_ID=big-pickle
73
81
  # STT_API_KEY=
74
82
  # STT_MODEL=
75
83
  # STT_LANGUAGE=
84
+
85
+ # Text-to-Speech credentials (optional)
86
+ # TTS reply behavior is controlled globally with /tts and persisted in settings.json.
87
+ # Set both TTS_API_URL and TTS_API_KEY to enable audio replies.
88
+ # TTS_API_URL=
89
+ # TTS_API_KEY=
90
+ # TTS_MODEL=gpt-4o-mini-tts
91
+ # TTS_VOICE=alloy
package/README.md CHANGED
@@ -31,7 +31,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
31
31
  - **Subagent activity** — watch live subagent progress in chat, including the current task, agent, model, and active tool step
32
32
  - **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
33
33
  - **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
34
- - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
34
+ - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, and optionally enable spoken replies with `/tts`
35
35
  - **File attachments** — send images, PDF documents, and any text-based files to OpenCode (code, logs, configs etc.)
36
36
  - **Scheduled tasks** — schedule prompts to run later or on a recurring interval; see [Scheduled Tasks](#scheduled-tasks)
37
37
  - **Context control** — compact context when it gets too large, right from the chat
@@ -109,6 +109,7 @@ opencode-telegram config
109
109
  | `/abort` | Abort the current task |
110
110
  | `/sessions` | Browse and switch between recent sessions |
111
111
  | `/projects` | Switch between OpenCode projects |
112
+ | `/tts` | Toggle audio replies |
112
113
  | `/rename` | Rename the current session |
113
114
  | `/commands` | Browse and run custom commands |
114
115
  | `/task` | Create a scheduled task |
@@ -147,30 +148,37 @@ When installed via npm, the configuration wizard handles the initial setup. The
147
148
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
148
149
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
149
150
 
150
- | Variable | Description | Required | Default |
151
- | -------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
152
- | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
153
- | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
154
- | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
155
- | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
156
- | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
157
- | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
158
- | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
159
- | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
160
- | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
161
- | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
162
- | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
163
- | `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
164
- | `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
165
- | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
166
- | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
167
- | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
168
- | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
169
- | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | |
170
- | `STT_API_KEY` | API key for your STT provider | No | |
171
- | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
172
- | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
173
- | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
151
+ | Variable | Description | Required | Default |
152
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ------------------------ |
153
+ | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
154
+ | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
155
+ | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
156
+ | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
157
+ | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
158
+ | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
159
+ | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
160
+ | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
161
+ | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
162
+ | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
163
+ | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
164
+ | `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
165
+ | `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
166
+ | `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` |
167
+ | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
168
+ | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
169
+ | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
170
+ | `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
171
+ | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
172
+ | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
173
+ | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
174
+ | `STT_API_KEY` | API key for your STT provider | No | |
175
+ | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
176
+ | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
177
+ | `TTS_API_URL` | TTS API base URL | No | — |
178
+ | `TTS_API_KEY` | TTS API key | No | — |
179
+ | `TTS_MODEL` | TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
180
+ | `TTS_VOICE` | OpenAI-compatible TTS voice name | No | `alloy` |
181
+ | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
174
182
 
175
183
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
176
184
 
@@ -183,6 +191,17 @@ If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
183
191
  3. Show recognized text in chat
184
192
  4. Send the recognized text to OpenCode as a normal prompt
185
193
 
194
+ If TTS credentials are configured, you can toggle spoken replies globally with `/tts`. The preference is stored in `settings.json` and persists across restarts.
195
+
196
+ TTS configuration example:
197
+
198
+ ```env
199
+ TTS_API_URL=https://api.openai.com/v1
200
+ TTS_API_KEY=your-tts-api-key
201
+ TTS_MODEL=gpt-4o-mini-tts
202
+ TTS_VOICE=alloy
203
+ ```
204
+
186
205
  Supported provider examples (Whisper-compatible):
187
206
 
188
207
  - **OpenAI**
@@ -29,6 +29,31 @@ export async function getAvailableAgents() {
29
29
  }
30
30
  }
31
31
  const DEFAULT_AGENT = "build";
32
+ function pickFallbackAgent(agents) {
33
+ const defaultAgent = agents.find((agent) => agent.name === DEFAULT_AGENT);
34
+ if (defaultAgent) {
35
+ return defaultAgent.name;
36
+ }
37
+ return agents[0]?.name ?? DEFAULT_AGENT;
38
+ }
39
+ export async function resolveProjectAgent(preferredAgent) {
40
+ const requestedAgent = preferredAgent ?? getCurrentAgent() ?? DEFAULT_AGENT;
41
+ const project = getCurrentProject();
42
+ if (!project) {
43
+ return requestedAgent;
44
+ }
45
+ const agents = await getAvailableAgents();
46
+ if (agents.length === 0) {
47
+ return requestedAgent;
48
+ }
49
+ if (agents.some((agent) => agent.name === requestedAgent)) {
50
+ return requestedAgent;
51
+ }
52
+ const fallbackAgent = pickFallbackAgent(agents);
53
+ logger.warn(`[AgentManager] Agent "${requestedAgent}" is not available for project ${project.worktree}. Falling back to "${fallbackAgent}".`);
54
+ setCurrentAgent(fallbackAgent);
55
+ return fallbackAgent;
56
+ }
32
57
  /**
33
58
  * Get current agent from last session message or settings.
34
59
  * Falls back to "build" if nothing is stored.
@@ -38,10 +63,13 @@ export async function fetchCurrentAgent() {
38
63
  const storedAgent = getCurrentAgent();
39
64
  const session = getCurrentSession();
40
65
  const project = getCurrentProject();
41
- if (!session || !project) {
42
- // No active session, return stored agent from settings
66
+ if (!project) {
67
+ // No active project, return stored agent from settings
43
68
  return storedAgent ?? DEFAULT_AGENT;
44
69
  }
70
+ if (!session) {
71
+ return resolveProjectAgent(storedAgent ?? DEFAULT_AGENT);
72
+ }
45
73
  try {
46
74
  const { data: messages, error } = await opencodeClient.session.messages({
47
75
  sessionID: session.id,
@@ -50,7 +78,7 @@ export async function fetchCurrentAgent() {
50
78
  });
51
79
  if (error || !messages || messages.length === 0) {
52
80
  logger.debug("[AgentManager] No messages found, using stored agent");
53
- return storedAgent ?? DEFAULT_AGENT;
81
+ return resolveProjectAgent(storedAgent ?? DEFAULT_AGENT);
54
82
  }
55
83
  const lastAgent = messages[0].info.agent;
56
84
  logger.debug(`[AgentManager] Current agent from session: ${lastAgent}`);
@@ -58,17 +86,17 @@ export async function fetchCurrentAgent() {
58
86
  // Session messages may contain stale agent until next prompt is sent.
59
87
  if (storedAgent && lastAgent !== storedAgent) {
60
88
  logger.debug(`[AgentManager] Using stored agent "${storedAgent}" instead of session agent "${lastAgent}"`);
61
- return storedAgent;
89
+ return resolveProjectAgent(storedAgent);
62
90
  }
63
91
  // No stored agent yet: sync from session history
64
92
  if (lastAgent && lastAgent !== storedAgent) {
65
93
  setCurrentAgent(lastAgent);
66
94
  }
67
- return lastAgent || storedAgent || DEFAULT_AGENT;
95
+ return resolveProjectAgent(lastAgent || storedAgent || DEFAULT_AGENT);
68
96
  }
69
97
  catch (err) {
70
98
  logger.error("[AgentManager] Error fetching current agent:", err);
71
- return storedAgent ?? DEFAULT_AGENT;
99
+ return resolveProjectAgent(storedAgent ?? DEFAULT_AGENT);
72
100
  }
73
101
  }
74
102
  /**
@@ -22,5 +22,11 @@ export function getAgentEmoji(agentName) {
22
22
  export function getAgentDisplayName(agentName) {
23
23
  const emoji = getAgentEmoji(agentName);
24
24
  const capitalizedName = agentName.charAt(0).toUpperCase() + agentName.slice(1);
25
- return `${emoji} ${capitalizedName} Mode`;
25
+ return `${emoji} ${capitalizedName}`;
26
+ }
27
+ /**
28
+ * Get reply keyboard label for agent button.
29
+ */
30
+ export function getAgentButtonLabel(agentName) {
31
+ return `${getAgentDisplayName(agentName)} Agent`;
26
32
  }
@@ -5,7 +5,7 @@ import { clearSession, getCurrentSession, setCurrentSession, } from "../../sessi
5
5
  import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
6
6
  import { interactionManager } from "../../interaction/manager.js";
7
7
  import { summaryAggregator } from "../../summary/aggregator.js";
8
- import { getStoredAgent } from "../../agent/manager.js";
8
+ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
9
9
  import { getStoredModel } from "../../model/manager.js";
10
10
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
11
11
  import { logger } from "../../utils/logger.js";
@@ -279,7 +279,7 @@ async function executeCommand(ctx, deps, params) {
279
279
  await ctx.reply(t("bot.session_busy"));
280
280
  return;
281
281
  }
282
- const currentAgent = getStoredAgent();
282
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
283
283
  const storedModel = getStoredModel();
284
284
  const model = storedModel.providerID && storedModel.modelID
285
285
  ? `${storedModel.providerID}/${storedModel.modelID}`
@@ -8,6 +8,7 @@ const COMMAND_DEFINITIONS = [
8
8
  { command: "new", descriptionKey: "cmd.description.new" },
9
9
  { command: "abort", descriptionKey: "cmd.description.stop" },
10
10
  { command: "sessions", descriptionKey: "cmd.description.sessions" },
11
+ { command: "tts", descriptionKey: "cmd.description.tts" },
11
12
  { command: "projects", descriptionKey: "cmd.description.projects" },
12
13
  { command: "task", descriptionKey: "cmd.description.task" },
13
14
  { command: "tasklist", descriptionKey: "cmd.description.tasklist" },
@@ -6,7 +6,7 @@ import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
6
  import { summaryAggregator } from "../../summary/aggregator.js";
7
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
8
8
  import { keyboardManager } from "../../keyboard/manager.js";
9
- import { getStoredAgent } from "../../agent/manager.js";
9
+ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
10
10
  import { getStoredModel } from "../../model/manager.js";
11
11
  import { formatVariantForButton } from "../../variant/manager.js";
12
12
  import { createMainKeyboard } from "../utils/keyboard.js";
@@ -54,10 +54,11 @@ export async function newCommand(ctx) {
54
54
  logger.error("[Bot] Error creating pinned message:", err);
55
55
  }
56
56
  // Get current state for keyboard
57
- const currentAgent = getStoredAgent();
57
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
58
58
  const currentModel = getStoredModel();
59
59
  const contextInfo = pinnedMessageManager.getContextInfo();
60
60
  const variantName = formatVariantForButton(currentModel.variant || "default");
61
+ keyboardManager.updateAgent(currentAgent);
61
62
  const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
62
63
  await ctx.reply(t("new.created", { title: session.title }), {
63
64
  reply_markup: keyboard,
@@ -6,7 +6,7 @@ import { clearSession } from "../../session/manager.js";
6
6
  import { summaryAggregator } from "../../summary/aggregator.js";
7
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
8
8
  import { keyboardManager } from "../../keyboard/manager.js";
9
- import { getStoredAgent } from "../../agent/manager.js";
9
+ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
10
10
  import { getStoredModel } from "../../model/manager.js";
11
11
  import { formatVariantForButton } from "../../variant/manager.js";
12
12
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
@@ -201,10 +201,11 @@ export async function handleProjectSelect(ctx) {
201
201
  // Reset context to 0 (no session selected) with current model's limit
202
202
  keyboardManager.updateContext(0, contextLimit);
203
203
  // Get current state for keyboard (with context = 0)
204
- const currentAgent = getStoredAgent();
204
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
205
205
  const currentModel = getStoredModel();
206
206
  const contextInfo = { tokensUsed: 0, tokensLimit: contextLimit };
207
207
  const variantName = formatVariantForButton(currentModel.variant || "default");
208
+ keyboardManager.updateAgent(currentAgent);
208
209
  const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo, variantName);
209
210
  const projectName = selectedProject.name || selectedProject.worktree;
210
211
  await ctx.answerCallbackQuery();
@@ -1,5 +1,6 @@
1
1
  import { InlineKeyboard } from "grammy";
2
2
  import { opencodeClient } from "../../opencode/client.js";
3
+ import { resolveProjectAgent } from "../../agent/manager.js";
3
4
  import { setCurrentSession } from "../../session/manager.js";
4
5
  import { getCurrentProject } from "../../settings/manager.js";
5
6
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
@@ -214,6 +215,8 @@ export async function handleSessionSelect(ctx) {
214
215
  }
215
216
  if (ctx.chat) {
216
217
  const chatId = ctx.chat.id;
218
+ const currentAgent = await resolveProjectAgent();
219
+ keyboardManager.updateAgent(currentAgent);
217
220
  // Update keyboard with loaded context (callback executes async via setImmediate, so update manually)
218
221
  const contextInfo = pinnedMessageManager.getContextInfo();
219
222
  if (contextInfo) {
@@ -1,6 +1,6 @@
1
1
  import { opencodeClient } from "../../opencode/client.js";
2
2
  import { getCurrentSession } from "../../session/manager.js";
3
- import { getCurrentProject } from "../../settings/manager.js";
3
+ import { getCurrentProject, isTtsEnabled } from "../../settings/manager.js";
4
4
  import { fetchCurrentAgent } from "../../agent/manager.js";
5
5
  import { getAgentDisplayName } from "../../agent/types.js";
6
6
  import { fetchCurrentModel } from "../../model/manager.js";
@@ -22,6 +22,9 @@ export async function statusCommand(ctx) {
22
22
  if (data.version) {
23
23
  message += `${t("status.line.version", { version: data.version })}\n`;
24
24
  }
25
+ message += `${t("status.line.tts", {
26
+ tts: isTtsEnabled() ? t("status.tts.on") : t("status.tts.off"),
27
+ })}\n`;
25
28
  // Add process management information
26
29
  if (processManager.isRunning()) {
27
30
  const uptime = processManager.getUptime();
@@ -33,7 +36,7 @@ export async function statusCommand(ctx) {
33
36
  else {
34
37
  message += `${t("status.line.managed_no")}\n`;
35
38
  }
36
- // Add agent mode information
39
+ // Add agent information
37
40
  const currentAgent = await fetchCurrentAgent();
38
41
  const agentDisplay = currentAgent
39
42
  ? getAgentDisplayName(currentAgent)
@@ -0,0 +1,13 @@
1
+ import { isTtsConfigured } from "../../tts/client.js";
2
+ import { isTtsEnabled, setTtsEnabled } from "../../settings/manager.js";
3
+ import { t } from "../../i18n/index.js";
4
+ export async function ttsCommand(ctx) {
5
+ const enabled = !isTtsEnabled();
6
+ if (enabled && !isTtsConfigured()) {
7
+ await ctx.reply(t("tts.not_configured"));
8
+ return;
9
+ }
10
+ setTtsEnabled(enabled);
11
+ const message = enabled ? t("tts.enabled") : t("tts.disabled");
12
+ await ctx.reply(message);
13
+ }
@@ -1,6 +1,6 @@
1
1
  import { InlineKeyboard } from "grammy";
2
2
  import { selectAgent, getAvailableAgents, fetchCurrentAgent } from "../../agent/manager.js";
3
- import { getAgentDisplayName, getAgentEmoji } from "../../agent/types.js";
3
+ import { getAgentDisplayName } from "../../agent/types.js";
4
4
  import { getStoredModel } from "../../model/manager.js";
5
5
  import { formatVariantForButton } from "../../variant/manager.js";
6
6
  import { logger } from "../../utils/logger.js";
@@ -81,11 +81,10 @@ export async function buildAgentSelectionMenu(currentAgent) {
81
81
  }
82
82
  // Add button for each agent
83
83
  agents.forEach((agent) => {
84
- const emoji = getAgentEmoji(agent.name);
85
84
  const isActive = agent.name === currentAgent;
86
85
  const label = isActive
87
- ? `✅ ${emoji} ${agent.name.toUpperCase()}`
88
- : `${emoji} ${agent.name.charAt(0).toUpperCase() + agent.name.slice(1)}`;
86
+ ? `✅ ${getAgentDisplayName(agent.name)}`
87
+ : getAgentDisplayName(agent.name);
89
88
  keyboard.text(label, `agent:${agent.name}`).row();
90
89
  });
91
90
  return keyboard;
@@ -4,7 +4,7 @@ import { formatModelForDisplay } from "../../model/types.js";
4
4
  import { formatVariantForButton } from "../../variant/manager.js";
5
5
  import { logger } from "../../utils/logger.js";
6
6
  import { createMainKeyboard } from "../utils/keyboard.js";
7
- import { getStoredAgent } from "../../agent/manager.js";
7
+ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
8
8
  import { pinnedMessageManager } from "../../pinned/manager.js";
9
9
  import { keyboardManager } from "../../keyboard/manager.js";
10
10
  import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
@@ -61,11 +61,12 @@ export async function handleModelSelect(ctx) {
61
61
  // Refresh context limit for new model
62
62
  await pinnedMessageManager.refreshContextLimit();
63
63
  // Update Reply Keyboard with new model and context
64
- const currentAgent = getStoredAgent();
64
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
65
65
  const contextInfo = pinnedMessageManager.getContextInfo() ??
66
66
  (pinnedMessageManager.getContextLimit() > 0
67
67
  ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
68
68
  : null);
69
+ keyboardManager.updateAgent(currentAgent);
69
70
  if (contextInfo) {
70
71
  keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
71
72
  }
@@ -1,8 +1,8 @@
1
1
  import { opencodeClient } from "../../opencode/client.js";
2
2
  import { clearSession, getCurrentSession, setCurrentSession } from "../../session/manager.js";
3
3
  import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
4
- import { getCurrentProject } from "../../settings/manager.js";
5
- import { getStoredAgent } from "../../agent/manager.js";
4
+ import { getCurrentProject, isTtsEnabled } from "../../settings/manager.js";
5
+ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
6
6
  import { getStoredModel } from "../../model/manager.js";
7
7
  import { formatVariantForButton } from "../../variant/manager.js";
8
8
  import { createMainKeyboard } from "../utils/keyboard.js";
@@ -20,12 +20,24 @@ import { foregroundSessionState } from "../../scheduled-task/foreground-state.js
20
20
  /** Module-level references for async callbacks that don't have ctx. */
21
21
  let botInstance = null;
22
22
  let chatIdInstance = null;
23
+ const promptResponseModes = new Map();
23
24
  export function getPromptBotInstance() {
24
25
  return botInstance;
25
26
  }
26
27
  export function getPromptChatId() {
27
28
  return chatIdInstance;
28
29
  }
30
+ export function setPromptResponseMode(sessionId, responseMode) {
31
+ promptResponseModes.set(sessionId, responseMode);
32
+ }
33
+ export function clearPromptResponseMode(sessionId) {
34
+ promptResponseModes.delete(sessionId);
35
+ }
36
+ export function consumePromptResponseMode(sessionId) {
37
+ const responseMode = promptResponseModes.get(sessionId) ?? null;
38
+ promptResponseModes.delete(sessionId);
39
+ return responseMode;
40
+ }
29
41
  async function isSessionBusy(sessionId, directory) {
30
42
  try {
31
43
  const { data, error } = await opencodeClient.session.status({ directory });
@@ -72,8 +84,9 @@ async function resetMismatchedSessionContext() {
72
84
  * @param fileParts - Optional file parts (for photo/document attachments)
73
85
  * @returns true if the prompt was dispatched, false if it was blocked/failed early.
74
86
  */
75
- export async function processUserPrompt(ctx, text, deps, fileParts = []) {
87
+ export async function processUserPrompt(ctx, text, deps, fileParts = [], options = {}) {
76
88
  const { bot, ensureEventSubscription } = deps;
89
+ const responseMode = options.responseMode ?? (isTtsEnabled() ? "text_and_tts" : "text_only");
77
90
  const currentProject = getCurrentProject();
78
91
  if (!currentProject) {
79
92
  await ctx.reply(t("bot.project_not_selected"));
@@ -118,10 +131,11 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
118
131
  catch (err) {
119
132
  logger.error("[Bot] Error creating pinned message for new session:", err);
120
133
  }
121
- const currentAgent = getStoredAgent();
134
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
122
135
  const currentModel = getStoredModel();
123
136
  const contextInfo = pinnedMessageManager.getContextInfo();
124
137
  const variantName = formatVariantForButton(currentModel.variant || "default");
138
+ keyboardManager.updateAgent(currentAgent);
125
139
  const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
126
140
  await ctx.reply(t("bot.session_created", { title: session.title }), {
127
141
  reply_markup: keyboard,
@@ -149,7 +163,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
149
163
  return false;
150
164
  }
151
165
  try {
152
- const currentAgent = getStoredAgent();
166
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
153
167
  const storedModel = getStoredModel();
154
168
  // Build parts array with text and files
155
169
  const parts = [];
@@ -195,6 +209,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
195
209
  };
196
210
  logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
197
211
  foregroundSessionState.markBusy(currentSession.id);
212
+ setPromptResponseMode(currentSession.id, responseMode);
198
213
  // CRITICAL: DO NOT wait for session.prompt to complete.
199
214
  // If we wait, the handler will not finish and grammY will not call getUpdates,
200
215
  // which blocks receiving button callback_query updates.
@@ -205,6 +220,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
205
220
  onSuccess: ({ error }) => {
206
221
  if (error) {
207
222
  foregroundSessionState.markIdle(currentSession.id);
223
+ clearPromptResponseMode(currentSession.id);
208
224
  const details = formatErrorDetails(error, 6000);
209
225
  logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
210
226
  logger.error("[Bot] session.prompt error details:", details);
@@ -217,6 +233,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
217
233
  },
218
234
  onError: (error) => {
219
235
  foregroundSessionState.markIdle(currentSession.id);
236
+ clearPromptResponseMode(currentSession.id);
220
237
  const details = formatErrorDetails(error, 6000);
221
238
  logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
222
239
  logger.error("[Bot] session.prompt background failure details:", details);
@@ -1,7 +1,7 @@
1
1
  import { InlineKeyboard } from "grammy";
2
2
  import { getAvailableVariants, getCurrentVariant, setCurrentVariant, formatVariantForDisplay, formatVariantForButton, } from "../../variant/manager.js";
3
3
  import { getStoredModel } from "../../model/manager.js";
4
- import { getStoredAgent } from "../../agent/manager.js";
4
+ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
5
5
  import { logger } from "../../utils/logger.js";
6
6
  import { keyboardManager } from "../../keyboard/manager.js";
7
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
@@ -47,11 +47,12 @@ export async function handleVariantSelect(ctx) {
47
47
  keyboardManager.updateModel(updatedModel);
48
48
  keyboardManager.updateVariant(variantId);
49
49
  // Build keyboard with correct context info
50
- const currentAgent = getStoredAgent();
50
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
51
51
  const contextInfo = pinnedMessageManager.getContextInfo() ??
52
52
  (pinnedMessageManager.getContextLimit() > 0
53
53
  ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
54
54
  : null);
55
+ keyboardManager.updateAgent(currentAgent);
55
56
  if (contextInfo) {
56
57
  keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
57
58
  }