@grinev/opencode-telegram-bot 0.18.0 → 0.19.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
@@ -15,6 +15,13 @@ TELEGRAM_ALLOWED_USER_ID=
15
15
  # OpenCode API URL (optional, default: http://localhost:4096)
16
16
  # OPENCODE_API_URL=http://localhost:4096
17
17
 
18
+ # Automatically restart a local OpenCode server when health-checks fail (default: false)
19
+ # Only works when OPENCODE_API_URL points to localhost / 127.0.0.1 / ::1 / 0.0.0.0.
20
+ # OPENCODE_AUTO_RESTART_ENABLED=false
21
+
22
+ # OpenCode health monitor interval in seconds when auto-restart is enabled (default: 300)
23
+ # OPENCODE_MONITOR_INTERVAL_SEC=300
24
+
18
25
  # OpenCode Server Authentication (optional)
19
26
  # OPENCODE_SERVER_USERNAME=opencode
20
27
  # OPENCODE_SERVER_PASSWORD=
@@ -105,8 +112,22 @@ OPENCODE_MODEL_ID=big-pickle
105
112
 
106
113
  # Text-to-Speech credentials (optional)
107
114
  # TTS reply behavior is controlled globally with /tts and persisted in settings.json.
108
- # Set both TTS_API_URL and TTS_API_KEY to enable audio replies.
115
+ # Provider: "openai" (default) or "google".
116
+ #
117
+ # --- OpenAI-compatible (default) ---
118
+ # Set TTS_API_URL and TTS_API_KEY to any OpenAI-compatible TTS endpoint.
109
119
  # TTS_API_URL=
110
120
  # TTS_API_KEY=
111
121
  # TTS_MODEL=gpt-4o-mini-tts
112
122
  # TTS_VOICE=alloy
123
+ #
124
+ # --- Google Cloud TTS ---
125
+ # 1. Create a project at https://console.cloud.google.com
126
+ # 2. Enable "Cloud Text-to-Speech API"
127
+ # 3. Create a Service Account (IAM & Admin > Service Accounts)
128
+ # with role "Cloud Text-to-Speech API User"
129
+ # 4. Download the JSON key file
130
+ # Free tier: 1M characters/month (WaveNet/Neural2), 4M/month (Standard voices)
131
+ # TTS_PROVIDER=google
132
+ # TTS_VOICE=en-US-Studio-O
133
+ # GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
package/README.md CHANGED
@@ -138,6 +138,7 @@ opencode-telegram config
138
138
  | `/rename` | Rename the current session |
139
139
  | `/commands` | Browse and run custom commands |
140
140
  | `/skills` | Browse and run OpenCode skills |
141
+ | `/mcps` | Browse and toggle MCP servers |
141
142
  | `/task` | Create a scheduled task |
142
143
  | `/tasklist` | Browse and delete scheduled tasks |
143
144
  | `/opencode_start` | Start the local OpenCode server on the bot machine |
@@ -194,6 +195,8 @@ When installed via npm, the configuration wizard handles the initial setup. The
194
195
  | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
195
196
  | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
196
197
  | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
198
+ | `OPENCODE_AUTO_RESTART_ENABLED` | Automatically restart a local OpenCode server when health-checks fail | No | `false` |
199
+ | `OPENCODE_MONITOR_INTERVAL_SEC` | Health monitor interval in seconds when OpenCode auto-restart is enabled | No | `300` |
197
200
  | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
198
201
  | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
199
202
  | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
@@ -218,10 +221,12 @@ When installed via npm, the configuration wizard handles the initial setup. The
218
221
  | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
219
222
  | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
220
223
  | `STT_NOTE_PROMPT` | Optional note prepended to the LLM prompt as `[Note: ...]` for voice transcriptions; empty / `false` / `0` disable it | No | — |
221
- | `TTS_API_URL` | TTS API base URL | No | |
222
- | `TTS_API_KEY` | TTS API key | No | — |
223
- | `TTS_MODEL` | TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
224
- | `TTS_VOICE` | OpenAI-compatible TTS voice name | No | `alloy` |
224
+ | `TTS_PROVIDER` | TTS provider: `openai` for OpenAI-compatible APIs or `google` for Google Cloud TTS | No | `openai` |
225
+ | `TTS_API_URL` | OpenAI-compatible TTS API base URL | No | — |
226
+ | `TTS_API_KEY` | OpenAI-compatible TTS API key | No | |
227
+ | `TTS_MODEL` | OpenAI-compatible TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
228
+ | `TTS_VOICE` | TTS voice name. Defaults to `alloy` for OpenAI-compatible APIs and `en-US-Studio-O` for Google Cloud TTS | No | provider-specific |
229
+ | `GOOGLE_APPLICATION_CREDENTIALS` | Path to a Google Cloud service account JSON key file for `TTS_PROVIDER=google` | No | — |
225
230
  | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
226
231
  | `LOG_RETENTION` | Number of log files to keep: launch files in `sources`, daily files in `installed` | No | `10` |
227
232
 
@@ -242,15 +247,24 @@ If `STT_NOTE_PROMPT` is set to a non-empty value other than `false` or `0`, the
242
247
 
243
248
  If TTS credentials are configured, you can toggle spoken replies globally with `/tts`. The preference is stored in `settings.json` and persists across restarts.
244
249
 
245
- TTS configuration example:
250
+ OpenAI-compatible TTS configuration example:
246
251
 
247
252
  ```env
253
+ TTS_PROVIDER=openai
248
254
  TTS_API_URL=https://api.openai.com/v1
249
255
  TTS_API_KEY=your-tts-api-key
250
256
  TTS_MODEL=gpt-4o-mini-tts
251
257
  TTS_VOICE=alloy
252
258
  ```
253
259
 
260
+ Google Cloud TTS configuration example:
261
+
262
+ ```env
263
+ TTS_PROVIDER=google
264
+ TTS_VOICE=en-US-Studio-O
265
+ GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
266
+ ```
267
+
254
268
  Supported provider examples (Whisper-compatible):
255
269
 
256
270
  - **OpenAI**
@@ -326,6 +340,7 @@ npm run dev
326
340
 
327
341
  - Ensure an OpenCode server is running at the configured `OPENCODE_API_URL` (default: `http://localhost:4096`)
328
342
  - For a local setup, you can start it with `opencode serve` or use `/opencode_start` in Telegram
343
+ - For VPS/systemd setups with scheduled tasks, enable `OPENCODE_AUTO_RESTART_ENABLED=true` to let the bot restart a local OpenCode server when health-checks fail
329
344
  - If `OPENCODE_API_URL` points to a remote server, verify that the address is reachable from the bot machine and that the remote server is healthy
330
345
 
331
346
  **No models in model picker**
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { cleanupBotRuntime, createBot } from "../bot/index.js";
4
4
  import { config } from "../config.js";
5
+ import { opencodeAutoRestartService } from "../opencode/auto-restart.js";
5
6
  import { loadSettings } from "../settings/manager.js";
6
7
  import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
7
8
  import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
@@ -39,6 +40,7 @@ export async function startBotApp() {
39
40
  logger.debug(`[Runtime] Application start mode: ${mode}`);
40
41
  await loadSettings();
41
42
  await reconcileStoredModelSelection();
43
+ await opencodeAutoRestartService.start();
42
44
  await warmupSessionDirectoryCache();
43
45
  const bot = createBot();
44
46
  await scheduledTaskRuntime.initialize(bot);
@@ -73,6 +75,7 @@ export async function startBotApp() {
73
75
  shutdownStarted = true;
74
76
  logger.info(`[App] Received ${signal}, shutting down...`);
75
77
  cleanupBotRuntime(`app_shutdown_${signal.toLowerCase()}`);
78
+ opencodeAutoRestartService.stop();
76
79
  scheduledTaskRuntime.shutdown();
77
80
  shutdownTimeout = setTimeout(() => {
78
81
  logger.warn(`[App] Shutdown did not finish in ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit.`);
@@ -114,6 +117,7 @@ export async function startBotApp() {
114
117
  shutdownTimeout = null;
115
118
  }
116
119
  cleanupBotRuntime("app_shutdown_complete");
120
+ opencodeAutoRestartService.stop();
117
121
  scheduledTaskRuntime.shutdown();
118
122
  await clearManagedServiceState().catch((error) => {
119
123
  logger.warn("[App] Failed to clear managed service state", error);
@@ -27,7 +27,12 @@ async function ensureAttachPinnedSession({ api, chatId, session, }) {
27
27
  if (pinnedState.sessionId === session.id && pinnedState.messageId) {
28
28
  return;
29
29
  }
30
- await pinnedMessageManager.onSessionChange(session.id, session.title);
30
+ if (pinnedState.messageId && pinnedState.sessionId === null) {
31
+ await pinnedMessageManager.restoreExistingSession(session.id, session.title);
32
+ }
33
+ else {
34
+ await pinnedMessageManager.onSessionChange(session.id, session.title);
35
+ }
31
36
  await pinnedMessageManager.loadContextFromHistory(session.id, session.directory);
32
37
  const contextInfo = pinnedMessageManager.getContextInfo();
33
38
  if (contextInfo) {
@@ -16,6 +16,7 @@ const COMMAND_DEFINITIONS = [
16
16
  { command: "rename", descriptionKey: "cmd.description.rename" },
17
17
  { command: "commands", descriptionKey: "cmd.description.commands" },
18
18
  { command: "skills", descriptionKey: "cmd.description.skills" },
19
+ { command: "mcps", descriptionKey: "cmd.description.mcps" },
19
20
  { command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
20
21
  { command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
21
22
  { command: "open", descriptionKey: "cmd.description.open" },
@@ -0,0 +1,394 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { opencodeClient } from "../../opencode/client.js";
3
+ import { getCurrentProject } from "../../settings/manager.js";
4
+ import { interactionManager } from "../../interaction/manager.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { t } from "../../i18n/index.js";
7
+ const MCPS_CALLBACK_PREFIX = "mcps:";
8
+ const MCPS_CALLBACK_SELECT_PREFIX = `${MCPS_CALLBACK_PREFIX}select:`;
9
+ const MCPS_CALLBACK_TOGGLE = `${MCPS_CALLBACK_PREFIX}toggle`;
10
+ const MCPS_CALLBACK_BACK = `${MCPS_CALLBACK_PREFIX}back`;
11
+ const MCPS_CALLBACK_CANCEL = `${MCPS_CALLBACK_PREFIX}cancel`;
12
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
13
+ function normalizeDirectoryForApi(directory) {
14
+ return directory.replace(/\\/g, "/");
15
+ }
16
+ function getCallbackMessageId(ctx) {
17
+ const message = ctx.callbackQuery?.message;
18
+ if (!message || !("message_id" in message)) {
19
+ return null;
20
+ }
21
+ const messageId = message.message_id;
22
+ return typeof messageId === "number" ? messageId : null;
23
+ }
24
+ function getStatusLabel(status) {
25
+ switch (status.status) {
26
+ case "connected":
27
+ return t("mcps.status.connected");
28
+ case "disabled":
29
+ return t("mcps.status.disabled");
30
+ case "failed":
31
+ return t("mcps.status.failed");
32
+ case "needs_auth":
33
+ return t("mcps.status.needs_auth");
34
+ case "needs_client_registration":
35
+ return t("mcps.status.needs_client_registration");
36
+ default:
37
+ return t("common.unknown");
38
+ }
39
+ }
40
+ function getStatusEmoji(status) {
41
+ switch (status.status) {
42
+ case "connected":
43
+ return "🟢";
44
+ case "disabled":
45
+ return "🔴";
46
+ case "failed":
47
+ return "⚠️";
48
+ case "needs_auth":
49
+ return "🔒";
50
+ case "needs_client_registration":
51
+ return "🔒";
52
+ default:
53
+ return "❓";
54
+ }
55
+ }
56
+ function formatMcpButtonLabel(server) {
57
+ const rawLabel = `${getStatusEmoji(server.status)} ${server.name}`;
58
+ if (rawLabel.length <= MAX_INLINE_BUTTON_LABEL_LENGTH) {
59
+ return rawLabel;
60
+ }
61
+ return `${rawLabel.slice(0, MAX_INLINE_BUTTON_LABEL_LENGTH - 3)}...`;
62
+ }
63
+ function buildMcpsListKeyboard(servers) {
64
+ const keyboard = new InlineKeyboard();
65
+ servers.forEach((server, index) => {
66
+ keyboard
67
+ .text(formatMcpButtonLabel(server), `${MCPS_CALLBACK_SELECT_PREFIX}${index}`)
68
+ .row();
69
+ });
70
+ keyboard.text(t("inline.button.cancel"), MCPS_CALLBACK_CANCEL);
71
+ return keyboard;
72
+ }
73
+ function buildMcpsDetailKeyboard(server) {
74
+ const keyboard = new InlineKeyboard();
75
+ let hasToggleButton = false;
76
+ if (server.status.status === "connected") {
77
+ keyboard.text(t("mcps.button.disable"), MCPS_CALLBACK_TOGGLE);
78
+ hasToggleButton = true;
79
+ }
80
+ else if (server.status.status === "disabled" || server.status.status === "failed") {
81
+ keyboard.text(t("mcps.button.enable"), MCPS_CALLBACK_TOGGLE);
82
+ hasToggleButton = true;
83
+ }
84
+ if (hasToggleButton) {
85
+ keyboard.row();
86
+ }
87
+ keyboard.text(t("mcps.button.back"), MCPS_CALLBACK_BACK);
88
+ keyboard.text(t("inline.button.cancel"), MCPS_CALLBACK_CANCEL);
89
+ return keyboard;
90
+ }
91
+ function buildMcpsDetailText(server) {
92
+ const lines = [];
93
+ lines.push(t("mcps.detail.title", { name: server.name }));
94
+ lines.push("");
95
+ lines.push(t("mcps.detail.status", { status: getStatusLabel(server.status) }));
96
+ if (server.status.status === "failed" || server.status.status === "needs_client_registration") {
97
+ lines.push(t("mcps.detail.error", { error: server.status.error }));
98
+ }
99
+ if (server.status.status === "needs_auth" || server.status.status === "needs_client_registration") {
100
+ lines.push("");
101
+ lines.push(t("mcps.auth_required"));
102
+ }
103
+ return lines.join("\n");
104
+ }
105
+ function parseMcpsServers(value) {
106
+ if (!value || typeof value !== "object") {
107
+ return null;
108
+ }
109
+ if (Array.isArray(value)) {
110
+ const servers = [];
111
+ for (const item of value) {
112
+ if (!item || typeof item !== "object") {
113
+ return null;
114
+ }
115
+ const name = item.name;
116
+ const status = item.status;
117
+ if (typeof name !== "string" || !status || typeof status !== "object") {
118
+ return null;
119
+ }
120
+ const s = status;
121
+ if (typeof s.status !== "string") {
122
+ return null;
123
+ }
124
+ const mcpStatus = { status: s.status };
125
+ if ("error" in s && typeof s.error === "string") {
126
+ mcpStatus.error = s.error;
127
+ }
128
+ servers.push({ name, status: mcpStatus });
129
+ }
130
+ return servers;
131
+ }
132
+ const entries = Object.entries(value);
133
+ const servers = [];
134
+ for (const [name, status] of entries) {
135
+ if (!status || typeof status !== "object") {
136
+ return null;
137
+ }
138
+ const s = status;
139
+ if (typeof s.status !== "string") {
140
+ return null;
141
+ }
142
+ const mcpStatus = { status: s.status };
143
+ if ("error" in s && typeof s.error === "string") {
144
+ mcpStatus.error = s.error;
145
+ }
146
+ servers.push({ name, status: mcpStatus });
147
+ }
148
+ return servers;
149
+ }
150
+ function parseMcpsMetadata(state) {
151
+ if (!state || state.kind !== "custom") {
152
+ return null;
153
+ }
154
+ const flow = state.metadata.flow;
155
+ const stage = state.metadata.stage;
156
+ const messageId = state.metadata.messageId;
157
+ const projectDirectory = state.metadata.projectDirectory;
158
+ if (flow !== "mcps" || typeof messageId !== "number" || typeof projectDirectory !== "string") {
159
+ return null;
160
+ }
161
+ const servers = parseMcpsServers(state.metadata.servers);
162
+ if (!servers) {
163
+ return null;
164
+ }
165
+ if (stage === "list") {
166
+ return {
167
+ flow,
168
+ stage,
169
+ messageId,
170
+ projectDirectory,
171
+ servers,
172
+ };
173
+ }
174
+ if (stage === "detail") {
175
+ const serverName = state.metadata.serverName;
176
+ if (typeof serverName !== "string" || !serverName.trim()) {
177
+ return null;
178
+ }
179
+ return {
180
+ flow,
181
+ stage,
182
+ messageId,
183
+ projectDirectory,
184
+ serverName,
185
+ servers,
186
+ };
187
+ }
188
+ return null;
189
+ }
190
+ function clearMcpsInteraction(reason) {
191
+ const metadata = parseMcpsMetadata(interactionManager.getSnapshot());
192
+ if (metadata) {
193
+ interactionManager.clear(reason);
194
+ }
195
+ }
196
+ function parseSelectIndex(data) {
197
+ if (!data.startsWith(MCPS_CALLBACK_SELECT_PREFIX)) {
198
+ return null;
199
+ }
200
+ const index = Number(data.slice(MCPS_CALLBACK_SELECT_PREFIX.length));
201
+ if (!Number.isInteger(index) || index < 0) {
202
+ return null;
203
+ }
204
+ return index;
205
+ }
206
+ async function getMcpServerList(projectDirectory) {
207
+ const { data, error } = await opencodeClient.mcp.status({
208
+ directory: normalizeDirectoryForApi(projectDirectory),
209
+ });
210
+ if (error || !data) {
211
+ throw error || new Error("No MCP status data received");
212
+ }
213
+ const servers = parseMcpsServers(data);
214
+ if (!servers) {
215
+ throw new Error("Invalid MCP status data format");
216
+ }
217
+ return servers;
218
+ }
219
+ async function toggleMcpServer(projectDirectory, serverName, enable) {
220
+ const params = {
221
+ name: serverName,
222
+ directory: normalizeDirectoryForApi(projectDirectory),
223
+ };
224
+ if (enable) {
225
+ const { error } = await opencodeClient.mcp.connect(params);
226
+ if (error) {
227
+ throw error;
228
+ }
229
+ }
230
+ else {
231
+ const { error } = await opencodeClient.mcp.disconnect(params);
232
+ if (error) {
233
+ throw error;
234
+ }
235
+ }
236
+ }
237
+ export async function mcpsCommand(ctx) {
238
+ try {
239
+ const currentProject = getCurrentProject();
240
+ if (!currentProject) {
241
+ await ctx.reply(t("bot.project_not_selected"));
242
+ return;
243
+ }
244
+ const servers = await getMcpServerList(currentProject.worktree);
245
+ if (servers.length === 0) {
246
+ await ctx.reply(t("mcps.empty"));
247
+ return;
248
+ }
249
+ const keyboard = buildMcpsListKeyboard(servers);
250
+ const message = await ctx.reply(t("mcps.select"), {
251
+ reply_markup: keyboard,
252
+ });
253
+ interactionManager.start({
254
+ kind: "custom",
255
+ expectedInput: "callback",
256
+ metadata: {
257
+ flow: "mcps",
258
+ stage: "list",
259
+ messageId: message.message_id,
260
+ projectDirectory: currentProject.worktree,
261
+ servers,
262
+ },
263
+ });
264
+ }
265
+ catch (error) {
266
+ logger.error("[Mcps] Error fetching MCP servers list:", error);
267
+ await ctx.reply(t("mcps.fetch_error"));
268
+ }
269
+ }
270
+ export async function handleMcpsCallback(ctx) {
271
+ const data = ctx.callbackQuery?.data;
272
+ if (!data || !data.startsWith(MCPS_CALLBACK_PREFIX)) {
273
+ return false;
274
+ }
275
+ const metadata = parseMcpsMetadata(interactionManager.getSnapshot());
276
+ const callbackMessageId = getCallbackMessageId(ctx);
277
+ if (!metadata || callbackMessageId === null || metadata.messageId !== callbackMessageId) {
278
+ await ctx.answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true });
279
+ return true;
280
+ }
281
+ try {
282
+ if (data === MCPS_CALLBACK_CANCEL) {
283
+ clearMcpsInteraction("mcps_cancelled");
284
+ await ctx.answerCallbackQuery({ text: t("inline.cancelled_callback") });
285
+ await ctx.deleteMessage().catch(() => { });
286
+ return true;
287
+ }
288
+ if (data === MCPS_CALLBACK_BACK) {
289
+ if (metadata.stage !== "detail") {
290
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
291
+ return true;
292
+ }
293
+ const servers = await getMcpServerList(metadata.projectDirectory);
294
+ const keyboard = buildMcpsListKeyboard(servers);
295
+ await ctx.editMessageText(t("mcps.select"), { reply_markup: keyboard });
296
+ await ctx.answerCallbackQuery();
297
+ interactionManager.transition({
298
+ expectedInput: "callback",
299
+ metadata: {
300
+ flow: "mcps",
301
+ stage: "list",
302
+ messageId: metadata.messageId,
303
+ projectDirectory: metadata.projectDirectory,
304
+ servers,
305
+ },
306
+ });
307
+ return true;
308
+ }
309
+ if (data === MCPS_CALLBACK_TOGGLE) {
310
+ if (metadata.stage !== "detail") {
311
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
312
+ return true;
313
+ }
314
+ const serverName = metadata.serverName;
315
+ const server = metadata.servers.find((s) => s.name === serverName);
316
+ if (!server) {
317
+ await ctx.answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true });
318
+ return true;
319
+ }
320
+ const enable = server.status.status !== "connected";
321
+ await ctx.answerCallbackQuery({ text: enable ? t("mcps.enabling") : t("mcps.disabling") });
322
+ await toggleMcpServer(metadata.projectDirectory, serverName, enable);
323
+ const updatedServers = await getMcpServerList(metadata.projectDirectory);
324
+ const updatedServer = updatedServers.find((s) => s.name === serverName);
325
+ if (!updatedServer) {
326
+ await ctx.editMessageText(t("mcps.select"), {
327
+ reply_markup: buildMcpsListKeyboard(updatedServers),
328
+ });
329
+ interactionManager.transition({
330
+ expectedInput: "callback",
331
+ metadata: {
332
+ flow: "mcps",
333
+ stage: "list",
334
+ messageId: metadata.messageId,
335
+ projectDirectory: metadata.projectDirectory,
336
+ servers: updatedServers,
337
+ },
338
+ });
339
+ return true;
340
+ }
341
+ await ctx.editMessageText(buildMcpsDetailText(updatedServer), {
342
+ reply_markup: buildMcpsDetailKeyboard(updatedServer),
343
+ });
344
+ interactionManager.transition({
345
+ expectedInput: "callback",
346
+ metadata: {
347
+ flow: "mcps",
348
+ stage: "detail",
349
+ messageId: metadata.messageId,
350
+ projectDirectory: metadata.projectDirectory,
351
+ serverName: updatedServer.name,
352
+ servers: updatedServers,
353
+ },
354
+ });
355
+ return true;
356
+ }
357
+ if (data.startsWith(MCPS_CALLBACK_SELECT_PREFIX)) {
358
+ if (metadata.stage !== "list") {
359
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
360
+ return true;
361
+ }
362
+ const serverIndex = parseSelectIndex(data);
363
+ const server = serverIndex === null ? undefined : metadata.servers[serverIndex];
364
+ if (!server) {
365
+ await ctx.answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true });
366
+ return true;
367
+ }
368
+ await ctx.answerCallbackQuery();
369
+ await ctx.editMessageText(buildMcpsDetailText(server), {
370
+ reply_markup: buildMcpsDetailKeyboard(server),
371
+ });
372
+ interactionManager.transition({
373
+ expectedInput: "callback",
374
+ metadata: {
375
+ flow: "mcps",
376
+ stage: "detail",
377
+ messageId: metadata.messageId,
378
+ projectDirectory: metadata.projectDirectory,
379
+ serverName: server.name,
380
+ servers: metadata.servers,
381
+ },
382
+ });
383
+ return true;
384
+ }
385
+ await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
386
+ return true;
387
+ }
388
+ catch (error) {
389
+ logger.error("[Mcps] Error handling MCP callback:", error);
390
+ clearMcpsInteraction("mcps_callback_error");
391
+ await ctx.answerCallbackQuery({ text: t("mcps.toggle_error") }).catch(() => { });
392
+ return true;
393
+ }
394
+ }
@@ -1,7 +1,6 @@
1
- import { spawn } from "node:child_process";
2
1
  import { config } from "../../config.js";
3
2
  import { opencodeClient } from "../../opencode/client.js";
4
- import { resolveLocalOpencodeTarget } from "../../opencode/process.js";
3
+ import { resolveLocalOpencodeTarget, startLocalOpencodeServer } from "../../opencode/process.js";
5
4
  import { logger } from "../../utils/logger.js";
6
5
  import { t } from "../../i18n/index.js";
7
6
  import { editBotText } from "../utils/telegram-text.js";
@@ -50,14 +49,7 @@ export async function opencodeStartCommand(ctx) {
50
49
  // Server not accessible, continue with start.
51
50
  }
52
51
  const statusMessage = await ctx.reply(t("opencode_start.starting"));
53
- const isWindows = process.platform === "win32";
54
- const command = isWindows ? "cmd.exe" : "opencode";
55
- const args = isWindows ? ["/c", "opencode", "serve"] : ["serve"];
56
- const childProcess = spawn(command, args, {
57
- detached: true,
58
- stdio: "ignore",
59
- windowsHide: isWindows,
60
- });
52
+ const childProcess = startLocalOpencodeServer(localTarget);
61
53
  childProcess.once("error", (error) => {
62
54
  logger.error("[Bot] OpenCode server process failed to start", error);
63
55
  });
package/dist/bot/index.js CHANGED
@@ -26,6 +26,7 @@ import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands
26
26
  import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
27
27
  import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
28
28
  import { skillsCommand, handleSkillsCallback, handleSkillTextArguments, } from "./commands/skills.js";
29
+ import { mcpsCommand, handleMcpsCallback } from "./commands/mcps.js";
29
30
  import { ttsCommand } from "./commands/tts.js";
30
31
  import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
31
32
  import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
@@ -873,6 +874,7 @@ export function createBot() {
873
874
  bot.command("rename", renameCommand);
874
875
  bot.command("commands", commandsCommand);
875
876
  bot.command("skills", skillsCommand);
877
+ bot.command("mcps", mcpsCommand);
876
878
  bot.on("message:text", unknownCommandMiddleware);
877
879
  bot.on("callback_query:data", async (ctx) => {
878
880
  logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
@@ -902,7 +904,8 @@ export function createBot() {
902
904
  const handledRenameCancel = await handleRenameCancel(ctx);
903
905
  const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
904
906
  const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
905
- logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}`);
907
+ const handledMcps = await handleMcpsCallback(ctx);
908
+ logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`);
906
909
  if (!handledInlineCancel &&
907
910
  !handledSession &&
908
911
  !handledProject &&
@@ -918,7 +921,8 @@ export function createBot() {
918
921
  !handledTaskList &&
919
922
  !handledRenameCancel &&
920
923
  !handledCommands &&
921
- !handledSkills) {
924
+ !handledSkills &&
925
+ !handledMcps) {
922
926
  logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
923
927
  await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
924
928
  }
package/dist/config.js CHANGED
@@ -50,6 +50,18 @@ function getOptionalMessageFormatModeEnvVar(key, defaultValue) {
50
50
  }
51
51
  return defaultValue;
52
52
  }
53
+ const VALID_TTS_PROVIDERS = ["openai", "google"];
54
+ function getOptionalTtsProviderEnvVar(key, defaultValue) {
55
+ const value = getEnvVar(key, false);
56
+ if (!value) {
57
+ return defaultValue;
58
+ }
59
+ const normalized = value.trim().toLowerCase();
60
+ if (VALID_TTS_PROVIDERS.includes(normalized)) {
61
+ return normalized;
62
+ }
63
+ return defaultValue;
64
+ }
53
65
  export const config = {
54
66
  telegram: {
55
67
  token: getEnvVar("TELEGRAM_BOT_TOKEN"),
@@ -60,6 +72,8 @@ export const config = {
60
72
  apiUrl: getEnvVar("OPENCODE_API_URL", false) || "http://localhost:4096",
61
73
  username: getEnvVar("OPENCODE_SERVER_USERNAME", false) || "opencode",
62
74
  password: getEnvVar("OPENCODE_SERVER_PASSWORD", false),
75
+ autoRestartEnabled: getOptionalBooleanEnvVar("OPENCODE_AUTO_RESTART_ENABLED", false),
76
+ monitorIntervalSec: getOptionalPositiveIntEnvVar("OPENCODE_MONITOR_INTERVAL_SEC", 300),
63
77
  model: {
64
78
  provider: getEnvVar("OPENCODE_MODEL_PROVIDER", true), // Required
65
79
  modelId: getEnvVar("OPENCODE_MODEL_ID", true), // Required
@@ -95,10 +109,15 @@ export const config = {
95
109
  language: getEnvVar("STT_LANGUAGE", false),
96
110
  notePrompt: getEnvVar("STT_NOTE_PROMPT", false),
97
111
  },
98
- tts: {
99
- apiUrl: getEnvVar("TTS_API_URL", false),
100
- apiKey: getEnvVar("TTS_API_KEY", false),
101
- model: getEnvVar("TTS_MODEL", false) || "gpt-4o-mini-tts",
102
- voice: getEnvVar("TTS_VOICE", false) || "alloy",
103
- },
112
+ tts: (() => {
113
+ const provider = getOptionalTtsProviderEnvVar("TTS_PROVIDER", "openai");
114
+ const defaultVoice = provider === "google" ? "en-US-Studio-O" : "alloy";
115
+ return {
116
+ apiUrl: getEnvVar("TTS_API_URL", false),
117
+ apiKey: getEnvVar("TTS_API_KEY", false),
118
+ provider,
119
+ model: getEnvVar("TTS_MODEL", false) || "gpt-4o-mini-tts",
120
+ voice: getEnvVar("TTS_VOICE", false) || defaultVoice,
121
+ };
122
+ })(),
104
123
  };