@grinev/opencode-telegram-bot 0.18.0 → 0.19.1
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 +22 -1
- package/README.md +20 -5
- package/dist/app/start-bot-app.js +4 -0
- package/dist/attach/service.js +6 -1
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/mcps.js +394 -0
- package/dist/bot/commands/opencode-start.js +2 -10
- package/dist/bot/commands/projects.js +5 -4
- package/dist/bot/commands/tasklist.js +3 -1
- package/dist/bot/handlers/prompt.js +15 -14
- package/dist/bot/index.js +6 -2
- package/dist/config.js +25 -6
- package/dist/i18n/de.js +21 -0
- package/dist/i18n/en.js +21 -0
- package/dist/i18n/es.js +21 -0
- package/dist/i18n/fr.js +21 -0
- package/dist/i18n/ru.js +21 -0
- package/dist/i18n/zh.js +21 -0
- package/dist/opencode/auto-restart.js +92 -0
- package/dist/opencode/process.js +18 -1
- package/dist/pinned/manager.js +21 -0
- package/dist/scheduled-task/executor.js +155 -8
- package/dist/scheduled-task/runtime.js +30 -5
- package/dist/session/cache-manager.js +19 -6
- package/dist/summary/aggregator.js +35 -0
- package/dist/summary/formatter.js +2 -2
- package/dist/summary/markdown-to-telegram-v2.js +99 -0
- package/dist/summary/subagent-formatter.js +23 -2
- package/dist/telegram/render/inline-renderer.js +18 -0
- package/dist/tts/client.js +94 -19
- package/dist/utils/logger.js +47 -1
- package/package.json +2 -2
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
|
-
#
|
|
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
|
-
| `
|
|
222
|
-
| `
|
|
223
|
-
| `
|
|
224
|
-
| `
|
|
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);
|
package/dist/attach/service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
});
|
|
@@ -153,11 +153,15 @@ export async function handleProjectSelect(ctx) {
|
|
|
153
153
|
if (!callbackQuery?.data) {
|
|
154
154
|
return false;
|
|
155
155
|
}
|
|
156
|
+
const page = parseProjectPageCallback(callbackQuery.data);
|
|
157
|
+
const isProjectSelection = callbackQuery.data.startsWith("project:");
|
|
158
|
+
if (page === null && !isProjectSelection) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
156
161
|
if (isForegroundBusy()) {
|
|
157
162
|
await replyBusyBlocked(ctx);
|
|
158
163
|
return true;
|
|
159
164
|
}
|
|
160
|
-
const page = parseProjectPageCallback(callbackQuery.data);
|
|
161
165
|
if (page !== null) {
|
|
162
166
|
const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
|
|
163
167
|
if (!isActiveMenu) {
|
|
@@ -182,9 +186,6 @@ export async function handleProjectSelect(ctx) {
|
|
|
182
186
|
}
|
|
183
187
|
return true;
|
|
184
188
|
}
|
|
185
|
-
if (!callbackQuery.data.startsWith("project:")) {
|
|
186
|
-
return false;
|
|
187
|
-
}
|
|
188
189
|
const projectId = callbackQuery.data.replace("project:", "");
|
|
189
190
|
const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
|
|
190
191
|
if (!isActiveMenu) {
|
|
@@ -108,10 +108,12 @@ function sortTasks(tasks) {
|
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
function formatTaskDetails(task) {
|
|
111
|
+
const variant = task.model.variant ? ` (${task.model.variant})` : "";
|
|
112
|
+
const model = `${task.model.providerID}/${task.model.modelID}${variant}`;
|
|
111
113
|
const cronLine = task.kind === "cron" ? `${t("tasklist.details.cron", { cron: task.cron })}\n` : "";
|
|
112
114
|
return t("tasklist.details", {
|
|
113
115
|
prompt: task.prompt,
|
|
114
|
-
project: task.projectWorktree,
|
|
116
|
+
project: `${task.projectWorktree}\n${t("status.line.model", { model })}`,
|
|
115
117
|
schedule: task.scheduleSummary,
|
|
116
118
|
cronLine,
|
|
117
119
|
timezone: task.timezone,
|