@grinev/opencode-telegram-bot 0.15.0 → 0.16.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 +16 -0
- package/README.md +22 -1
- package/dist/app/start-bot-app.js +82 -7
- package/dist/bot/assistant-run-state.js +60 -0
- package/dist/bot/commands/abort.js +2 -0
- package/dist/bot/commands/commands.js +10 -0
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/open.js +314 -0
- package/dist/bot/commands/projects.js +5 -38
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/handlers/inline-menu.js +9 -1
- package/dist/bot/handlers/prompt.js +11 -0
- package/dist/bot/index.js +187 -100
- package/dist/bot/streaming/response-streamer.js +26 -17
- package/dist/bot/utils/assistant-rendering.js +117 -0
- package/dist/bot/utils/assistant-run-footer.js +9 -0
- package/dist/bot/utils/browser-roots.js +140 -0
- package/dist/bot/utils/file-tree.js +92 -0
- package/dist/bot/utils/finalize-assistant-response.js +18 -24
- package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
- package/dist/bot/utils/switch-project.js +48 -0
- package/dist/bot/utils/telegram-text.js +95 -1
- package/dist/cli/args.js +36 -7
- package/dist/cli.js +133 -15
- package/dist/config.js +4 -0
- package/dist/i18n/de.js +15 -10
- package/dist/i18n/en.js +15 -10
- package/dist/i18n/es.js +15 -10
- package/dist/i18n/fr.js +15 -10
- package/dist/i18n/ru.js +15 -10
- package/dist/i18n/zh.js +15 -10
- package/dist/index.js +2 -0
- package/dist/project/manager.js +2 -1
- package/dist/scheduled-task/runtime.js +8 -0
- package/dist/service/manager.js +244 -0
- package/dist/service/runtime.js +19 -0
- package/dist/service/types.js +1 -0
- package/dist/summary/aggregator.js +17 -1
- package/dist/summary/formatter.js +2 -88
- package/dist/telegram/render/block-fallback.js +28 -0
- package/dist/telegram/render/block-parser.js +295 -0
- package/dist/telegram/render/block-renderer.js +457 -0
- package/dist/telegram/render/chunker.js +281 -0
- package/dist/telegram/render/inline-renderer.js +128 -0
- package/dist/telegram/render/markdown-normalizer.js +94 -0
- package/dist/telegram/render/pipeline.js +9 -0
- package/dist/telegram/render/types.js +1 -0
- package/dist/telegram/render/validator.js +160 -0
- package/dist/utils/logger.js +200 -73
- package/package.json +6 -2
package/.env.example
CHANGED
|
@@ -33,6 +33,11 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
33
33
|
# Use "debug" to see detailed diagnostic logs including all bot events
|
|
34
34
|
# LOG_LEVEL=info
|
|
35
35
|
|
|
36
|
+
# Number of log files to keep (default: 10)
|
|
37
|
+
# sources mode: keeps the latest N launch logs
|
|
38
|
+
# installed mode: keeps the latest N daily logs
|
|
39
|
+
# LOG_RETENTION=10
|
|
40
|
+
|
|
36
41
|
# Bot Configuration (optional)
|
|
37
42
|
# Maximum number of sessions shown in /sessions (default: 10)
|
|
38
43
|
# SESSIONS_LIST_LIMIT=10
|
|
@@ -64,11 +69,22 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
64
69
|
# Hide tool call service messages (default: false)
|
|
65
70
|
# HIDE_TOOL_CALL_MESSAGES=false
|
|
66
71
|
|
|
72
|
+
# Hide tool file edit documents sent as .txt attachments (default: false)
|
|
73
|
+
# HIDE_TOOL_FILE_MESSAGES=false
|
|
74
|
+
|
|
67
75
|
# Assistant message formatting mode (default: markdown)
|
|
68
76
|
# markdown = convert assistant replies to Telegram MarkdownV2
|
|
69
77
|
# raw = show assistant replies as plain text
|
|
70
78
|
# MESSAGE_FORMAT_MODE=markdown
|
|
71
79
|
|
|
80
|
+
# Directory Browser Roots (optional)
|
|
81
|
+
# Comma-separated list of paths that /open is allowed to browse.
|
|
82
|
+
# Supports ~ for home directory. Defaults to ~ when not set.
|
|
83
|
+
# Examples:
|
|
84
|
+
# OPEN_BROWSER_ROOTS=~/projects
|
|
85
|
+
# OPEN_BROWSER_ROOTS=~/projects,~/work,/opt/repos
|
|
86
|
+
# OPEN_BROWSER_ROOTS=
|
|
87
|
+
|
|
72
88
|
# Code File Settings (optional)
|
|
73
89
|
# Maximum file size in KB to send as document (default: 100)
|
|
74
90
|
# CODE_FILE_MAX_SIZE_KB=100
|
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://github.com/grinev/opencode-telegram-bot/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
|
+
[](https://x.com/grin_rus)
|
|
7
8
|
|
|
8
9
|
OpenCode Telegram Bot is a secure Telegram client for [OpenCode](https://opencode.ai) CLI that runs on your local machine.
|
|
9
10
|
|
|
@@ -72,7 +73,7 @@ opencode serve
|
|
|
72
73
|
The fastest way — run directly with `npx`:
|
|
73
74
|
|
|
74
75
|
```bash
|
|
75
|
-
npx @grinev/opencode-telegram-bot
|
|
76
|
+
npx @grinev/opencode-telegram-bot@latest
|
|
76
77
|
```
|
|
77
78
|
|
|
78
79
|
> Quick start is for npm usage. You do not need to clone this repository. If you run this command from the source directory (repository root), it may fail with `opencode-telegram: not found`. To run from sources, use the [Development](#development) section.
|
|
@@ -86,6 +87,20 @@ npm install -g @grinev/opencode-telegram-bot
|
|
|
86
87
|
opencode-telegram start
|
|
87
88
|
```
|
|
88
89
|
|
|
90
|
+
`start` runs in the foreground by default. This is the recommended mode for `systemd`, Docker, local debugging, and other external process managers.
|
|
91
|
+
|
|
92
|
+
To run the bot in the built-in background mode instead:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
opencode-telegram start --daemon
|
|
96
|
+
opencode-telegram status
|
|
97
|
+
opencode-telegram stop
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
> Built-in daemon mode is intended for standalone npm installs without an external supervisor. For `systemd`, `pm2`, or Docker, keep using `opencode-telegram start` without `--daemon`.
|
|
101
|
+
|
|
102
|
+
For Linux `systemd` setup, see [`docs/LINUX_SYSTEMD_SETUP.md`](./docs/LINUX_SYSTEMD_SETUP.md).
|
|
103
|
+
|
|
89
104
|
To reconfigure at any time:
|
|
90
105
|
|
|
91
106
|
```bash
|
|
@@ -109,6 +124,7 @@ opencode-telegram config
|
|
|
109
124
|
| `/abort` | Abort the current task |
|
|
110
125
|
| `/sessions` | Browse and switch between recent sessions |
|
|
111
126
|
| `/projects` | Switch between OpenCode projects |
|
|
127
|
+
| `/open` | Add a project by browsing directories |
|
|
112
128
|
| `/tts` | Toggle audio replies |
|
|
113
129
|
| `/rename` | Rename the current session |
|
|
114
130
|
| `/commands` | Browse and run custom commands |
|
|
@@ -161,12 +177,14 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
161
177
|
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
|
|
162
178
|
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
|
|
163
179
|
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
|
|
180
|
+
| `OPEN_BROWSER_ROOTS` | Comma-separated paths `/open` is allowed to browse (supports `~`) | No | `~` (home directory) |
|
|
164
181
|
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
|
|
165
182
|
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
|
|
166
183
|
| `BASH_TOOL_DISPLAY_MAX_LENGTH` | Maximum displayed length for `bash` tool commands in Telegram summaries; longer commands are truncated | No | `128` |
|
|
167
184
|
| `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
|
|
168
185
|
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
169
186
|
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
187
|
+
| `HIDE_TOOL_FILE_MESSAGES` | Hide file edit documents sent as `.txt` attachments (`edit_*.txt`, `write_*.txt`) | No | `false` |
|
|
170
188
|
| `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
|
|
171
189
|
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
|
|
172
190
|
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
|
|
@@ -179,9 +197,12 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
179
197
|
| `TTS_MODEL` | TTS model name passed to `/audio/speech` | No | `gpt-4o-mini-tts` |
|
|
180
198
|
| `TTS_VOICE` | OpenAI-compatible TTS voice name | No | `alloy` |
|
|
181
199
|
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
|
|
200
|
+
| `LOG_RETENTION` | Number of log files to keep: launch files in `sources`, daily files in `installed` | No | `10` |
|
|
182
201
|
|
|
183
202
|
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
|
|
184
203
|
|
|
204
|
+
Logs are written to `./logs` when running from sources and to the runtime config directory `logs/` folder in `installed` mode. Log rotation depends on runtime mode: `sources` creates one file per bot launch, while `installed` appends to one file per day. Old log files are removed according to `LOG_RETENTION`.
|
|
205
|
+
|
|
185
206
|
### Voice and Audio Transcription (Optional)
|
|
186
207
|
|
|
187
208
|
If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { createBot } from "../bot/index.js";
|
|
3
|
+
import { cleanupBotRuntime, createBot } from "../bot/index.js";
|
|
3
4
|
import { config } from "../config.js";
|
|
4
5
|
import { loadSettings } from "../settings/manager.js";
|
|
5
6
|
import { processManager } from "../process/manager.js";
|
|
@@ -8,7 +9,10 @@ import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
|
|
|
8
9
|
import { reconcileStoredModelSelection } from "../model/manager.js";
|
|
9
10
|
import { getRuntimeMode } from "../runtime/mode.js";
|
|
10
11
|
import { getRuntimePaths } from "../runtime/paths.js";
|
|
11
|
-
import {
|
|
12
|
+
import { clearServiceStateFile } from "../service/manager.js";
|
|
13
|
+
import { getServiceStateFilePathFromEnv, isServiceChildProcess } from "../service/runtime.js";
|
|
14
|
+
import { getLogFilePath, initializeLogger, logger } from "../utils/logger.js";
|
|
15
|
+
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
12
16
|
async function getBotVersion() {
|
|
13
17
|
try {
|
|
14
18
|
const packageJsonPath = new URL("../../package.json", import.meta.url);
|
|
@@ -22,11 +26,16 @@ async function getBotVersion() {
|
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
export async function startBotApp() {
|
|
29
|
+
await initializeLogger();
|
|
25
30
|
const mode = getRuntimeMode();
|
|
26
31
|
const runtimePaths = getRuntimePaths();
|
|
27
32
|
const version = await getBotVersion();
|
|
33
|
+
const logFilePath = getLogFilePath();
|
|
28
34
|
logger.info(`Starting OpenCode Telegram Bot v${version}...`);
|
|
29
35
|
logger.info(`Config loaded from ${runtimePaths.envFilePath}`);
|
|
36
|
+
if (logFilePath) {
|
|
37
|
+
logger.info(`Logs are written to ${logFilePath}`);
|
|
38
|
+
}
|
|
30
39
|
logger.info(`Allowed User ID: ${config.telegram.allowedUserId}`);
|
|
31
40
|
logger.debug(`[Runtime] Application start mode: ${mode}`);
|
|
32
41
|
await loadSettings();
|
|
@@ -35,15 +44,81 @@ export async function startBotApp() {
|
|
|
35
44
|
await warmupSessionDirectoryCache();
|
|
36
45
|
const bot = createBot();
|
|
37
46
|
await scheduledTaskRuntime.initialize(bot);
|
|
47
|
+
let shutdownStarted = false;
|
|
48
|
+
let serviceStateCleared = false;
|
|
49
|
+
let shutdownTimeout = null;
|
|
50
|
+
const clearManagedServiceState = async () => {
|
|
51
|
+
if (!isServiceChildProcess() || serviceStateCleared) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const stateFilePath = getServiceStateFilePathFromEnv();
|
|
55
|
+
if (!stateFilePath) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
await fs.access(stateFilePath);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code === "ENOENT") {
|
|
63
|
+
serviceStateCleared = true;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
await clearServiceStateFile(stateFilePath);
|
|
69
|
+
serviceStateCleared = true;
|
|
70
|
+
};
|
|
71
|
+
const shutdown = (signal) => {
|
|
72
|
+
if (shutdownStarted) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
shutdownStarted = true;
|
|
76
|
+
logger.info(`[App] Received ${signal}, shutting down...`);
|
|
77
|
+
cleanupBotRuntime(`app_shutdown_${signal.toLowerCase()}`);
|
|
78
|
+
scheduledTaskRuntime.shutdown();
|
|
79
|
+
shutdownTimeout = setTimeout(() => {
|
|
80
|
+
logger.warn(`[App] Shutdown did not finish in ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit.`);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
83
|
+
shutdownTimeout.unref?.();
|
|
84
|
+
try {
|
|
85
|
+
bot.stop();
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
logger.warn("[App] Failed to stop Telegram bot cleanly", error);
|
|
89
|
+
}
|
|
90
|
+
void clearManagedServiceState().catch((error) => {
|
|
91
|
+
logger.warn("[App] Failed to clear managed service state", error);
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
const handleSigint = () => shutdown("SIGINT");
|
|
95
|
+
const handleSigterm = () => shutdown("SIGTERM");
|
|
96
|
+
process.on("SIGINT", handleSigint);
|
|
97
|
+
process.on("SIGTERM", handleSigterm);
|
|
38
98
|
const webhookInfo = await bot.api.getWebhookInfo();
|
|
39
99
|
if (webhookInfo.url) {
|
|
40
100
|
logger.info(`[Bot] Webhook detected: ${webhookInfo.url}, removing...`);
|
|
41
101
|
await bot.api.deleteWebhook();
|
|
42
102
|
logger.info("[Bot] Webhook removed, switching to long polling");
|
|
43
103
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
104
|
+
try {
|
|
105
|
+
await bot.start({
|
|
106
|
+
onStart: (botInfo) => {
|
|
107
|
+
logger.info(`Bot @${botInfo.username} started!`);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
process.off("SIGINT", handleSigint);
|
|
113
|
+
process.off("SIGTERM", handleSigterm);
|
|
114
|
+
if (shutdownTimeout) {
|
|
115
|
+
clearTimeout(shutdownTimeout);
|
|
116
|
+
shutdownTimeout = null;
|
|
117
|
+
}
|
|
118
|
+
cleanupBotRuntime("app_shutdown_complete");
|
|
119
|
+
scheduledTaskRuntime.shutdown();
|
|
120
|
+
await clearManagedServiceState().catch((error) => {
|
|
121
|
+
logger.warn("[App] Failed to clear managed service state", error);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
49
124
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.js";
|
|
2
|
+
class AssistantRunState {
|
|
3
|
+
runs = new Map();
|
|
4
|
+
startRun(sessionId, info) {
|
|
5
|
+
if (!sessionId) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
this.runs.set(sessionId, {
|
|
9
|
+
sessionId,
|
|
10
|
+
startedAt: info.startedAt,
|
|
11
|
+
configuredAgent: info.configuredAgent,
|
|
12
|
+
configuredProviderID: info.configuredProviderID,
|
|
13
|
+
configuredModelID: info.configuredModelID,
|
|
14
|
+
hasCompletedResponse: false,
|
|
15
|
+
});
|
|
16
|
+
logger.debug(`[AssistantRunState] Started run: session=${sessionId}, agent=${info.configuredAgent || "unknown"}, model=${info.configuredProviderID || "unknown"}/${info.configuredModelID || "unknown"}`);
|
|
17
|
+
}
|
|
18
|
+
markResponseCompleted(sessionId, info) {
|
|
19
|
+
const run = this.runs.get(sessionId);
|
|
20
|
+
if (!run) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
run.hasCompletedResponse = true;
|
|
24
|
+
if (info?.agent) {
|
|
25
|
+
run.actualAgent = info.agent;
|
|
26
|
+
}
|
|
27
|
+
if (info?.providerID) {
|
|
28
|
+
run.actualProviderID = info.providerID;
|
|
29
|
+
}
|
|
30
|
+
if (info?.modelID) {
|
|
31
|
+
run.actualModelID = info.modelID;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
finishRun(sessionId, reason) {
|
|
35
|
+
const run = this.runs.get(sessionId) ?? null;
|
|
36
|
+
if (!run) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
this.runs.delete(sessionId);
|
|
40
|
+
logger.debug(`[AssistantRunState] Finished run: session=${sessionId}, reason=${reason}`);
|
|
41
|
+
return { ...run };
|
|
42
|
+
}
|
|
43
|
+
clearRun(sessionId, reason) {
|
|
44
|
+
if (!this.runs.delete(sessionId)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
logger.debug(`[AssistantRunState] Cleared run: session=${sessionId}, reason=${reason}`);
|
|
48
|
+
}
|
|
49
|
+
clearAll(reason) {
|
|
50
|
+
if (this.runs.size === 0) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
logger.debug(`[AssistantRunState] Cleared all runs: count=${this.runs.size}, reason=${reason}`);
|
|
54
|
+
this.runs.clear();
|
|
55
|
+
}
|
|
56
|
+
__resetForTests() {
|
|
57
|
+
this.runs.clear();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export const assistantRunState = new AssistantRunState();
|
|
@@ -6,6 +6,7 @@ import { summaryAggregator } from "../../summary/aggregator.js";
|
|
|
6
6
|
import { logger } from "../../utils/logger.js";
|
|
7
7
|
import { t } from "../../i18n/index.js";
|
|
8
8
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
9
|
+
import { assistantRunState } from "../assistant-run-state.js";
|
|
9
10
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
11
|
function abortLocalStreaming() {
|
|
11
12
|
stopEventListening();
|
|
@@ -86,6 +87,7 @@ export async function abortCurrentOperation(ctx, options = {}) {
|
|
|
86
87
|
const finalStatus = await pollSessionStatus(currentSession.id, currentSession.directory, 5000);
|
|
87
88
|
if (finalStatus === "idle" || finalStatus === "not-found") {
|
|
88
89
|
foregroundSessionState.markIdle(currentSession.id);
|
|
90
|
+
assistantRunState.clearRun(currentSession.id, "abort_confirmed");
|
|
89
91
|
if (notifyUser && chatId !== null && waitingMessageId !== null) {
|
|
90
92
|
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.success"));
|
|
91
93
|
}
|
|
@@ -12,6 +12,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
12
12
|
import { t } from "../../i18n/index.js";
|
|
13
13
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
14
14
|
import { config } from "../../config.js";
|
|
15
|
+
import { assistantRunState } from "../assistant-run-state.js";
|
|
15
16
|
const COMMANDS_CALLBACK_PREFIX = "commands:";
|
|
16
17
|
const COMMANDS_CALLBACK_SELECT_PREFIX = `${COMMANDS_CALLBACK_PREFIX}select:`;
|
|
17
18
|
const COMMANDS_CALLBACK_PAGE_PREFIX = `${COMMANDS_CALLBACK_PREFIX}page:`;
|
|
@@ -236,6 +237,7 @@ async function ensureSessionForProject(ctx, projectDirectory) {
|
|
|
236
237
|
clearSession();
|
|
237
238
|
summaryAggregator.clear();
|
|
238
239
|
foregroundSessionState.clearAll("session_mismatch_reset");
|
|
240
|
+
assistantRunState.clearAll("session_mismatch_reset");
|
|
239
241
|
await ctx.reply(t("bot.session_reset_project_mismatch"));
|
|
240
242
|
currentSession = null;
|
|
241
243
|
}
|
|
@@ -285,6 +287,12 @@ async function executeCommand(ctx, deps, params) {
|
|
|
285
287
|
? `${storedModel.providerID}/${storedModel.modelID}`
|
|
286
288
|
: undefined;
|
|
287
289
|
foregroundSessionState.markBusy(session.id);
|
|
290
|
+
assistantRunState.startRun(session.id, {
|
|
291
|
+
startedAt: Date.now(),
|
|
292
|
+
configuredAgent: currentAgent,
|
|
293
|
+
configuredProviderID: storedModel.providerID,
|
|
294
|
+
configuredModelID: storedModel.modelID,
|
|
295
|
+
});
|
|
288
296
|
safeBackgroundTask({
|
|
289
297
|
taskName: "session.command",
|
|
290
298
|
task: () => opencodeClient.session.command({
|
|
@@ -299,6 +307,7 @@ async function executeCommand(ctx, deps, params) {
|
|
|
299
307
|
onSuccess: ({ error }) => {
|
|
300
308
|
if (error) {
|
|
301
309
|
foregroundSessionState.markIdle(session.id);
|
|
310
|
+
assistantRunState.clearRun(session.id, "session_command_api_error");
|
|
302
311
|
logger.error("[Commands] OpenCode API returned an error for session.command", {
|
|
303
312
|
sessionId: session.id,
|
|
304
313
|
command: params.commandName,
|
|
@@ -312,6 +321,7 @@ async function executeCommand(ctx, deps, params) {
|
|
|
312
321
|
},
|
|
313
322
|
onError: (error) => {
|
|
314
323
|
foregroundSessionState.markIdle(session.id);
|
|
324
|
+
assistantRunState.clearRun(session.id, "session_command_background_error");
|
|
315
325
|
logger.error("[Commands] session.command background task failed", {
|
|
316
326
|
sessionId: session.id,
|
|
317
327
|
command: params.commandName,
|
|
@@ -16,6 +16,7 @@ const COMMAND_DEFINITIONS = [
|
|
|
16
16
|
{ command: "commands", descriptionKey: "cmd.description.commands" },
|
|
17
17
|
{ command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
|
|
18
18
|
{ command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
|
|
19
|
+
{ command: "open", descriptionKey: "cmd.description.open" },
|
|
19
20
|
{ command: "help", descriptionKey: "cmd.description.help" },
|
|
20
21
|
];
|
|
21
22
|
export function getLocalizedBotCommands() {
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { appendInlineMenuCancelButton, ensureActiveInlineMenu } from "../handlers/inline-menu.js";
|
|
4
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
5
|
+
import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
|
|
6
|
+
import { pathToDisplayPath, scanDirectory, buildEntryLabel, buildTreeHeader, isScanError, MAX_ENTRIES_PER_PAGE, } from "../utils/file-tree.js";
|
|
7
|
+
import { getBrowserRoots, isWithinAllowedRoot, isAllowedRoot } from "../utils/browser-roots.js";
|
|
8
|
+
import { upsertSessionDirectory } from "../../session/cache-manager.js";
|
|
9
|
+
import { getProjectByWorktree } from "../../project/manager.js";
|
|
10
|
+
import { switchToProject } from "../utils/switch-project.js";
|
|
11
|
+
import { logger } from "../../utils/logger.js";
|
|
12
|
+
import { t } from "../../i18n/index.js";
|
|
13
|
+
const CALLBACK_PREFIX = "open:";
|
|
14
|
+
const CALLBACK_NAV_PREFIX = "open:nav:";
|
|
15
|
+
const CALLBACK_SELECT_PREFIX = "open:sel:";
|
|
16
|
+
const CALLBACK_PAGE_PREFIX = "open:pg:";
|
|
17
|
+
const CALLBACK_ROOTS = "open:roots";
|
|
18
|
+
const MAX_BUTTON_LABEL_LENGTH = 64;
|
|
19
|
+
/**
|
|
20
|
+
* Separator used inside pagination callback data between the encoded path
|
|
21
|
+
* reference and the page number. We avoid `:` because it appears in Windows
|
|
22
|
+
* drive letters (e.g. `C:\`) and is already used as a prefix delimiter.
|
|
23
|
+
*/
|
|
24
|
+
const PAGE_SEPARATOR = "|";
|
|
25
|
+
function truncateLabel(label, maxLen = MAX_BUTTON_LABEL_LENGTH) {
|
|
26
|
+
if (label.length <= maxLen) {
|
|
27
|
+
return label;
|
|
28
|
+
}
|
|
29
|
+
return label.slice(0, Math.max(0, maxLen - 3)) + "...";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Encode a path into callback data. Telegram limits callback_data to 64 bytes.
|
|
33
|
+
* Long absolute paths can exceed this, so we encode them as a compact index
|
|
34
|
+
* when necessary. The index is stored in a module-level map that lives for the
|
|
35
|
+
* duration of the current inline menu interaction.
|
|
36
|
+
*/
|
|
37
|
+
const pathIndex = new Map();
|
|
38
|
+
let pathCounter = 0;
|
|
39
|
+
/** Clear the path index. Exported so it can be called on menu cancel/cleanup. */
|
|
40
|
+
export function clearOpenPathIndex() {
|
|
41
|
+
pathIndex.clear();
|
|
42
|
+
pathCounter = 0;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* @param prefix Callback-data prefix that precedes the path.
|
|
46
|
+
* @param fullPath Absolute path to encode.
|
|
47
|
+
* @param reserveBytes Extra bytes to reserve for a suffix that will be
|
|
48
|
+
* appended *after* the returned value (e.g. the page separator + digits in
|
|
49
|
+
* pagination callbacks). The total callback_data must stay ≤ 64 bytes.
|
|
50
|
+
*/
|
|
51
|
+
function encodePathForCallback(prefix, fullPath, reserveBytes = 0) {
|
|
52
|
+
const naive = `${prefix}${fullPath}`;
|
|
53
|
+
if (Buffer.byteLength(naive, "utf-8") + reserveBytes <= 64) {
|
|
54
|
+
return naive;
|
|
55
|
+
}
|
|
56
|
+
// Use a short numeric key instead
|
|
57
|
+
const key = `#${pathCounter++}`;
|
|
58
|
+
pathIndex.set(key, fullPath);
|
|
59
|
+
return `${prefix}${key}`;
|
|
60
|
+
}
|
|
61
|
+
function decodePathFromCallback(prefix, data) {
|
|
62
|
+
if (!data.startsWith(prefix)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const raw = data.slice(prefix.length);
|
|
66
|
+
if (raw.startsWith("#")) {
|
|
67
|
+
return pathIndex.get(raw) ?? null;
|
|
68
|
+
}
|
|
69
|
+
return raw;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Encode a pagination callback. The path part goes through the same 64-byte
|
|
73
|
+
* safe encoding used by nav/select callbacks, followed by a separator and
|
|
74
|
+
* the page number.
|
|
75
|
+
*
|
|
76
|
+
* We reserve bytes for the page suffix so the 64-byte check inside
|
|
77
|
+
* `encodePathForCallback` accounts for the complete final callback length.
|
|
78
|
+
*/
|
|
79
|
+
function encodePaginationCallback(currentPath, page) {
|
|
80
|
+
const pageSuffix = `${PAGE_SEPARATOR}${page}`;
|
|
81
|
+
const reserveBytes = Buffer.byteLength(pageSuffix, "utf-8");
|
|
82
|
+
const pathRef = encodePathForCallback(CALLBACK_PAGE_PREFIX, currentPath, reserveBytes);
|
|
83
|
+
return `${pathRef}${pageSuffix}`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Decode a pagination callback into { path, page } or null on failure.
|
|
87
|
+
*/
|
|
88
|
+
function decodePaginationCallback(data) {
|
|
89
|
+
if (!data.startsWith(CALLBACK_PAGE_PREFIX)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const payload = data.slice(CALLBACK_PAGE_PREFIX.length);
|
|
93
|
+
const sepIndex = payload.lastIndexOf(PAGE_SEPARATOR);
|
|
94
|
+
if (sepIndex < 0) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const pathRef = payload.slice(0, sepIndex);
|
|
98
|
+
const pageNum = Number.parseInt(payload.slice(sepIndex + 1), 10);
|
|
99
|
+
if (Number.isNaN(pageNum)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
// Resolve indexed path references
|
|
103
|
+
const resolvedPath = pathRef.startsWith("#") ? (pathIndex.get(pathRef) ?? null) : pathRef;
|
|
104
|
+
if (resolvedPath === null) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return { path: resolvedPath, page: pageNum };
|
|
108
|
+
}
|
|
109
|
+
function buildBrowseKeyboard(entries, currentPath, hasParent, page, totalCount) {
|
|
110
|
+
const keyboard = new InlineKeyboard();
|
|
111
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / MAX_ENTRIES_PER_PAGE));
|
|
112
|
+
// Directory entries
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
const label = truncateLabel(buildEntryLabel(entry));
|
|
115
|
+
keyboard.text(label, encodePathForCallback(CALLBACK_NAV_PREFIX, entry.fullPath)).row();
|
|
116
|
+
}
|
|
117
|
+
// Navigation: Up + Back to roots
|
|
118
|
+
// Suppress "Up" when at an allowed root (don't let user navigate above it)
|
|
119
|
+
const atRoot = isAllowedRoot(currentPath);
|
|
120
|
+
const showUp = hasParent && !atRoot;
|
|
121
|
+
const roots = getBrowserRoots();
|
|
122
|
+
const showRoots = roots.length > 1;
|
|
123
|
+
if (showUp || showRoots) {
|
|
124
|
+
if (showUp) {
|
|
125
|
+
const parentPath = path.dirname(currentPath);
|
|
126
|
+
keyboard.text(t("open.back"), encodePathForCallback(CALLBACK_NAV_PREFIX, parentPath));
|
|
127
|
+
}
|
|
128
|
+
if (showRoots) {
|
|
129
|
+
keyboard.text(t("open.roots"), CALLBACK_ROOTS);
|
|
130
|
+
}
|
|
131
|
+
keyboard.row();
|
|
132
|
+
}
|
|
133
|
+
// Pagination
|
|
134
|
+
if (totalPages > 1) {
|
|
135
|
+
if (page > 0) {
|
|
136
|
+
keyboard.text(t("open.prev_page"), encodePaginationCallback(currentPath, page - 1));
|
|
137
|
+
}
|
|
138
|
+
if (page < totalPages - 1) {
|
|
139
|
+
keyboard.text(t("open.next_page"), encodePaginationCallback(currentPath, page + 1));
|
|
140
|
+
}
|
|
141
|
+
keyboard.row();
|
|
142
|
+
}
|
|
143
|
+
// Select current folder
|
|
144
|
+
keyboard
|
|
145
|
+
.text(t("open.select_current"), encodePathForCallback(CALLBACK_SELECT_PREFIX, currentPath))
|
|
146
|
+
.row();
|
|
147
|
+
// Cancel
|
|
148
|
+
appendInlineMenuCancelButton(keyboard, "open");
|
|
149
|
+
return keyboard;
|
|
150
|
+
}
|
|
151
|
+
async function renderBrowseView(dirPath, page = 0) {
|
|
152
|
+
const result = await scanDirectory(dirPath, page);
|
|
153
|
+
if (isScanError(result)) {
|
|
154
|
+
return { error: result.error };
|
|
155
|
+
}
|
|
156
|
+
const { entries, totalCount, page: clampedPage, currentPath, displayPath, hasParent } = result;
|
|
157
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / MAX_ENTRIES_PER_PAGE));
|
|
158
|
+
const header = buildTreeHeader(displayPath, totalCount, clampedPage, totalPages);
|
|
159
|
+
const keyboard = buildBrowseKeyboard(entries, currentPath, hasParent, clampedPage, totalCount);
|
|
160
|
+
return { text: header, keyboard };
|
|
161
|
+
}
|
|
162
|
+
function buildRootsKeyboard() {
|
|
163
|
+
const keyboard = new InlineKeyboard();
|
|
164
|
+
const roots = getBrowserRoots();
|
|
165
|
+
for (const root of roots) {
|
|
166
|
+
const label = truncateLabel(`📂 ${pathToDisplayPath(root)}`);
|
|
167
|
+
keyboard.text(label, encodePathForCallback(CALLBACK_NAV_PREFIX, root)).row();
|
|
168
|
+
}
|
|
169
|
+
appendInlineMenuCancelButton(keyboard, "open");
|
|
170
|
+
return keyboard;
|
|
171
|
+
}
|
|
172
|
+
export async function openCommand(ctx) {
|
|
173
|
+
try {
|
|
174
|
+
if (isForegroundBusy()) {
|
|
175
|
+
await replyBusyBlocked(ctx);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Reset path index for new interaction
|
|
179
|
+
clearOpenPathIndex();
|
|
180
|
+
const roots = getBrowserRoots();
|
|
181
|
+
let text;
|
|
182
|
+
let keyboard;
|
|
183
|
+
if (roots.length === 1) {
|
|
184
|
+
// Single root — navigate directly into it
|
|
185
|
+
const view = await renderBrowseView(roots[0]);
|
|
186
|
+
if ("error" in view) {
|
|
187
|
+
await ctx.reply(t("open.scan_error", { error: view.error }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
text = view.text;
|
|
191
|
+
keyboard = view.keyboard;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// Multiple roots — show root selection
|
|
195
|
+
text = t("open.select_root");
|
|
196
|
+
keyboard = buildRootsKeyboard();
|
|
197
|
+
}
|
|
198
|
+
const message = await ctx.reply(text, { reply_markup: keyboard });
|
|
199
|
+
interactionManager.start({
|
|
200
|
+
kind: "inline",
|
|
201
|
+
expectedInput: "callback",
|
|
202
|
+
metadata: {
|
|
203
|
+
menuKind: "open",
|
|
204
|
+
messageId: message.message_id,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
logger.error("[Bot] Error opening directory browser:", error);
|
|
210
|
+
await ctx.reply(t("open.open_error"));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export async function handleOpenCallback(ctx) {
|
|
214
|
+
const data = ctx.callbackQuery?.data;
|
|
215
|
+
if (!data || !data.startsWith(CALLBACK_PREFIX)) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (isForegroundBusy()) {
|
|
219
|
+
await replyBusyBlocked(ctx);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
const isActiveMenu = await ensureActiveInlineMenu(ctx, "open");
|
|
223
|
+
if (!isActiveMenu) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
// Back to root selection (multi-root mode)
|
|
228
|
+
if (data === CALLBACK_ROOTS) {
|
|
229
|
+
await showRoots(ctx);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
// Navigate into a directory (including "up")
|
|
233
|
+
const navPath = decodePathFromCallback(CALLBACK_NAV_PREFIX, data);
|
|
234
|
+
if (navPath !== null) {
|
|
235
|
+
if (!isWithinAllowedRoot(navPath)) {
|
|
236
|
+
await ctx.answerCallbackQuery({ text: t("open.access_denied") });
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
await navigateTo(ctx, navPath);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
// Pagination
|
|
243
|
+
const pageInfo = decodePaginationCallback(data);
|
|
244
|
+
if (pageInfo !== null) {
|
|
245
|
+
if (!isWithinAllowedRoot(pageInfo.path)) {
|
|
246
|
+
await ctx.answerCallbackQuery({ text: t("open.access_denied") });
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
await navigateTo(ctx, pageInfo.path, pageInfo.page);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
// Select directory as project
|
|
253
|
+
const selectPath = decodePathFromCallback(CALLBACK_SELECT_PREFIX, data);
|
|
254
|
+
if (selectPath !== null) {
|
|
255
|
+
if (!isWithinAllowedRoot(selectPath)) {
|
|
256
|
+
await ctx.answerCallbackQuery({ text: t("open.access_denied") });
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
await selectDirectory(ctx, selectPath);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
logger.error("[Bot] Error handling open callback:", error);
|
|
266
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function showRoots(ctx) {
|
|
271
|
+
const text = t("open.select_root");
|
|
272
|
+
const keyboard = buildRootsKeyboard();
|
|
273
|
+
await ctx.answerCallbackQuery();
|
|
274
|
+
await ctx.editMessageText(text, { reply_markup: keyboard });
|
|
275
|
+
}
|
|
276
|
+
async function navigateTo(ctx, dirPath, page = 0) {
|
|
277
|
+
const view = await renderBrowseView(dirPath, page);
|
|
278
|
+
if ("error" in view) {
|
|
279
|
+
await ctx.answerCallbackQuery({ text: view.error });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
await ctx.answerCallbackQuery();
|
|
283
|
+
await ctx.editMessageText(view.text, {
|
|
284
|
+
reply_markup: view.keyboard,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async function selectDirectory(ctx, directory) {
|
|
288
|
+
const displayPath = pathToDisplayPath(directory);
|
|
289
|
+
try {
|
|
290
|
+
logger.info(`[Bot] Adding project directory: ${directory}`);
|
|
291
|
+
// Register directory in the session cache first — getProjectByWorktree
|
|
292
|
+
// needs the entry to exist so it can resolve the project. If the
|
|
293
|
+
// subsequent switch fails, the entry stays in the cache, which is
|
|
294
|
+
// acceptable: it's a real directory the user explicitly selected and
|
|
295
|
+
// will simply appear in /projects for retry.
|
|
296
|
+
await upsertSessionDirectory(directory, Date.now());
|
|
297
|
+
const projectInfo = await getProjectByWorktree(directory);
|
|
298
|
+
const replyKeyboard = await switchToProject(ctx, { ...projectInfo, name: displayPath }, "open_project_selected");
|
|
299
|
+
await ctx.answerCallbackQuery();
|
|
300
|
+
await ctx.reply(t("open.selected", { project: displayPath }), {
|
|
301
|
+
reply_markup: replyKeyboard,
|
|
302
|
+
});
|
|
303
|
+
// Clean up the inline menu message
|
|
304
|
+
await ctx.deleteMessage();
|
|
305
|
+
// Clear path index after selection
|
|
306
|
+
clearOpenPathIndex();
|
|
307
|
+
logger.info(`[Bot] Project added and selected: ${displayPath}`);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
logger.error("[Bot] Error selecting directory:", error);
|
|
311
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
|
|
312
|
+
await ctx.reply(t("open.select_error"));
|
|
313
|
+
}
|
|
314
|
+
}
|