@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.
Files changed (50) hide show
  1. package/.env.example +16 -0
  2. package/README.md +22 -1
  3. package/dist/app/start-bot-app.js +82 -7
  4. package/dist/bot/assistant-run-state.js +60 -0
  5. package/dist/bot/commands/abort.js +2 -0
  6. package/dist/bot/commands/commands.js +10 -0
  7. package/dist/bot/commands/definitions.js +1 -0
  8. package/dist/bot/commands/open.js +314 -0
  9. package/dist/bot/commands/projects.js +5 -38
  10. package/dist/bot/commands/start.js +2 -0
  11. package/dist/bot/handlers/inline-menu.js +9 -1
  12. package/dist/bot/handlers/prompt.js +11 -0
  13. package/dist/bot/index.js +187 -100
  14. package/dist/bot/streaming/response-streamer.js +26 -17
  15. package/dist/bot/utils/assistant-rendering.js +117 -0
  16. package/dist/bot/utils/assistant-run-footer.js +9 -0
  17. package/dist/bot/utils/browser-roots.js +140 -0
  18. package/dist/bot/utils/file-tree.js +92 -0
  19. package/dist/bot/utils/finalize-assistant-response.js +18 -24
  20. package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
  21. package/dist/bot/utils/switch-project.js +48 -0
  22. package/dist/bot/utils/telegram-text.js +95 -1
  23. package/dist/cli/args.js +36 -7
  24. package/dist/cli.js +133 -15
  25. package/dist/config.js +4 -0
  26. package/dist/i18n/de.js +15 -10
  27. package/dist/i18n/en.js +15 -10
  28. package/dist/i18n/es.js +15 -10
  29. package/dist/i18n/fr.js +15 -10
  30. package/dist/i18n/ru.js +15 -10
  31. package/dist/i18n/zh.js +15 -10
  32. package/dist/index.js +2 -0
  33. package/dist/project/manager.js +2 -1
  34. package/dist/scheduled-task/runtime.js +8 -0
  35. package/dist/service/manager.js +244 -0
  36. package/dist/service/runtime.js +19 -0
  37. package/dist/service/types.js +1 -0
  38. package/dist/summary/aggregator.js +17 -1
  39. package/dist/summary/formatter.js +2 -88
  40. package/dist/telegram/render/block-fallback.js +28 -0
  41. package/dist/telegram/render/block-parser.js +295 -0
  42. package/dist/telegram/render/block-renderer.js +457 -0
  43. package/dist/telegram/render/chunker.js +281 -0
  44. package/dist/telegram/render/inline-renderer.js +128 -0
  45. package/dist/telegram/render/markdown-normalizer.js +94 -0
  46. package/dist/telegram/render/pipeline.js +9 -0
  47. package/dist/telegram/render/types.js +1 -0
  48. package/dist/telegram/render/validator.js +160 -0
  49. package/dist/utils/logger.js +200 -73
  50. 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
  [![CI](https://github.com/grinev/opencode-telegram-bot/actions/workflows/ci.yml/badge.svg)](https://github.com/grinev/opencode-telegram-bot/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
7
+ [![Follow updates](https://img.shields.io/badge/-Follow%20updates-333333?logo=x)](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 { logger } from "../utils/logger.js";
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
- await bot.start({
45
- onStart: (botInfo) => {
46
- logger.info(`Bot @${botInfo.username} started!`);
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
+ }