@grinev/opencode-telegram-bot 0.21.1 → 0.21.2

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 (179) hide show
  1. package/.env.example +11 -1
  2. package/README.md +18 -5
  3. package/dist/app/{start-bot-app.js → bootstrap/start-bot-app.js} +16 -15
  4. package/dist/{bot/utils/assistant-run-footer.js → app/formatters/assistant-run-footer-formatter.js} +1 -1
  5. package/dist/{summary → app/formatters}/subagent-formatter.js +8 -3
  6. package/dist/{summary/formatter.js → app/formatters/summary-formatter.js} +4 -132
  7. package/dist/{summary → app/formatters}/tool-message-batcher.js +1 -1
  8. package/dist/{bot/assistant-run-state.js → app/managers/assistant-run-state-manager.js} +1 -1
  9. package/dist/{attach/manager.js → app/managers/attach-manager.js} +1 -1
  10. package/dist/{background-session/tracker.js → app/managers/background-session-manager.js} +2 -2
  11. package/dist/{scheduled-task/foreground-state.js → app/managers/foreground-session-state-manager.js} +1 -1
  12. package/dist/{interaction/manager.js → app/managers/interaction-manager.js} +31 -1
  13. package/dist/{permission/manager.js → app/managers/permission-manager.js} +1 -1
  14. package/dist/{question/manager.js → app/managers/question-manager.js} +1 -1
  15. package/dist/{rename/manager.js → app/managers/rename-manager.js} +1 -1
  16. package/dist/{scheduled-task/creation-manager.js → app/managers/scheduled-task-creation-manager.js} +2 -2
  17. package/dist/{summary/aggregator.js → app/managers/summary-aggregation-manager.js} +3 -3
  18. package/dist/{agent/manager.js → app/services/agent-selection-service.js} +4 -5
  19. package/dist/{attach/service.js → app/services/attach-service.js} +23 -44
  20. package/dist/{bot/utils/busy-reconciliation.js → app/services/busy-reconciliation-service.js} +12 -7
  21. package/dist/app/services/command-catalog-service.js +21 -0
  22. package/dist/{bot/utils/external-user-input.js → app/services/external-user-input-service.js} +4 -19
  23. package/dist/app/services/file-browser-service.js +265 -0
  24. package/dist/{bot/utils/file-download.js → app/services/file-download-service.js} +14 -28
  25. package/dist/app/services/mcp-catalog-service.js +79 -0
  26. package/dist/app/services/message-history-service.js +81 -0
  27. package/dist/{model/capabilities.js → app/services/model-capabilities-service.js} +2 -2
  28. package/dist/{model/context-limit.js → app/services/model-context-limit-service.js} +4 -4
  29. package/dist/{model/manager.js → app/services/model-selection-service.js} +4 -4
  30. package/dist/{project/manager.js → app/services/project-service.js} +3 -3
  31. package/dist/app/services/project-switch-service.js +39 -0
  32. package/dist/{bot/utils/busy-guard.js → app/services/run-control-service.js} +3 -14
  33. package/dist/{scheduled-task/executor.js → app/services/scheduled-task-executor-service.js} +5 -5
  34. package/dist/{scheduled-task/runtime.js → app/services/scheduled-task-runtime-service.js} +17 -56
  35. package/dist/{scheduled-task/schedule-parser.js → app/services/scheduled-task-schedule-parser-service.js} +3 -3
  36. package/dist/{scheduled-task/session-ignore.js → app/services/scheduled-task-session-ignore-service.js} +1 -1
  37. package/dist/{session/cache-manager.js → app/services/session-cache-service.js} +3 -3
  38. package/dist/{session/manager.js → app/services/session-service.js} +1 -1
  39. package/dist/app/services/skills-catalog-service.js +21 -0
  40. package/dist/{stt/client.js → app/services/stt-service.js} +2 -2
  41. package/dist/{tts/client.js → app/services/tts-service.js} +68 -5
  42. package/dist/{variant/manager.js → app/services/variant-selection-service.js} +3 -3
  43. package/dist/{scheduled-task/store.js → app/stores/scheduled-task-store.js} +3 -3
  44. package/dist/{settings/manager.js → app/stores/settings-store.js} +3 -3
  45. package/dist/app/types/worktree.js +1 -0
  46. package/dist/bot/{handlers/agent.js → callbacks/agent-selection-callback-handler.js} +8 -57
  47. package/dist/bot/callbacks/callback-router.js +110 -0
  48. package/dist/bot/{commands/commands.js → callbacks/command-catalog-callback-handler.js} +17 -197
  49. package/dist/bot/{handlers/context.js → callbacks/context-control-callback-handler.js} +3 -32
  50. package/dist/bot/callbacks/file-browser-callback-handler.js +219 -0
  51. package/dist/bot/callbacks/inline-menu-cancel-callback-handler.js +32 -0
  52. package/dist/bot/{commands/mcps.js → callbacks/mcp-catalog-callback-handler.js} +7 -172
  53. package/dist/bot/{commands/messages.js → callbacks/message-history-callback-handler.js} +17 -248
  54. package/dist/bot/{handlers/model.js → callbacks/model-selection-callback-handler.js} +10 -71
  55. package/dist/bot/callbacks/permission-callback-handler.js +114 -0
  56. package/dist/bot/callbacks/project-callback-handler.js +79 -0
  57. package/dist/bot/callbacks/question-callback-handler.js +159 -0
  58. package/dist/bot/{commands/rename.js → callbacks/rename-callback-handler.js} +6 -34
  59. package/dist/bot/{commands/tasklist.js → callbacks/scheduled-task-callback-handler.js} +83 -68
  60. package/dist/bot/{commands/sessions.js → callbacks/session-callback-handler.js} +19 -158
  61. package/dist/bot/{commands/skills.js → callbacks/skills-catalog-callback-handler.js} +8 -188
  62. package/dist/bot/{handlers/variant.js → callbacks/variant-selection-callback-handler.js} +7 -69
  63. package/dist/bot/callbacks/worktree-callback-handler.js +97 -0
  64. package/dist/bot/commands/{abort.js → abort-command.js} +6 -6
  65. package/dist/bot/commands/command-catalog-command.js +42 -0
  66. package/dist/bot/commands/{detach.js → detach-command.js} +8 -8
  67. package/dist/bot/commands/ls-command.js +48 -0
  68. package/dist/bot/commands/mcp-catalog-command.js +39 -0
  69. package/dist/bot/commands/messages-command.js +59 -0
  70. package/dist/bot/commands/{new.js → new-command.js} +12 -11
  71. package/dist/bot/commands/open-command.js +45 -0
  72. package/dist/bot/commands/{opencode-start.js → opencode-start-command.js} +1 -1
  73. package/dist/bot/commands/{opencode-stop.js → opencode-stop-command.js} +1 -1
  74. package/dist/bot/commands/projects-command.js +32 -0
  75. package/dist/bot/commands/rename-command.js +33 -0
  76. package/dist/bot/commands/sessions-command.js +42 -0
  77. package/dist/bot/commands/skills-catalog-command.js +42 -0
  78. package/dist/bot/commands/{start.js → start-command.js} +12 -12
  79. package/dist/bot/commands/{status.js → status-command.js} +9 -9
  80. package/dist/bot/commands/{task.js → task-command.js} +9 -81
  81. package/dist/bot/commands/tasklist-command.js +40 -0
  82. package/dist/bot/commands/{tts.js → tts-command.js} +2 -2
  83. package/dist/bot/commands/worktree-command.js +47 -0
  84. package/dist/bot/handlers/{document.js → document-handler.js} +3 -3
  85. package/dist/bot/handlers/{media-group.js → media-group-handler.js} +3 -3
  86. package/dist/bot/handlers/photo-handler.js +44 -0
  87. package/dist/bot/handlers/prompt.js +16 -16
  88. package/dist/bot/handlers/text-message-handler.js +61 -0
  89. package/dist/bot/handlers/tts-response-handler.js +29 -0
  90. package/dist/bot/handlers/{voice.js → voice-handler.js} +2 -2
  91. package/dist/bot/index.js +27 -1179
  92. package/dist/{keyboard/manager.js → bot/keyboards/keyboard-manager.js} +6 -6
  93. package/dist/bot/keyboards/keyboard-types.js +1 -0
  94. package/dist/bot/{utils/keyboard.js → keyboards/main-reply-keyboard.js} +2 -2
  95. package/dist/bot/menus/agent-selection-menu.js +54 -0
  96. package/dist/bot/menus/command-catalog-menu.js +101 -0
  97. package/dist/bot/menus/context-control-menu.js +33 -0
  98. package/dist/bot/menus/file-browser-menu.js +265 -0
  99. package/dist/bot/{handlers → menus}/inline-menu.js +4 -33
  100. package/dist/bot/menus/mcp-catalog-menu.js +97 -0
  101. package/dist/bot/menus/message-history-menu.js +107 -0
  102. package/dist/bot/menus/model-selection-menu.js +66 -0
  103. package/dist/bot/menus/permission-menu.js +116 -0
  104. package/dist/bot/{commands/projects.js → menus/project-selection-menu.js} +5 -105
  105. package/dist/bot/{handlers/question.js → menus/question-menu.js} +10 -166
  106. package/dist/bot/menus/rename-menu.js +6 -0
  107. package/dist/bot/menus/scheduled-task-menu.js +42 -0
  108. package/dist/bot/menus/session-selection-menu.js +115 -0
  109. package/dist/bot/menus/skills-catalog-menu.js +99 -0
  110. package/dist/bot/menus/variant-selection-menu.js +67 -0
  111. package/dist/bot/menus/worktree-selection-menu.js +76 -0
  112. package/dist/bot/{utils → messages}/assistant-rendering.js +2 -2
  113. package/dist/bot/messages/busy-blocked-renderer.js +11 -0
  114. package/dist/bot/messages/external-user-input-notification.js +22 -0
  115. package/dist/bot/messages/scheduled-task-delivery.js +63 -0
  116. package/dist/bot/{utils → messages}/send-downloaded-file.js +1 -1
  117. package/dist/bot/messages/summary-message-formatter.js +130 -0
  118. package/dist/{interaction/guard.js → bot/middleware/interaction-guard-decision.js} +11 -4
  119. package/dist/bot/middleware/interaction-guard.js +2 -2
  120. package/dist/bot/middleware/unknown-command.js +1 -1
  121. package/dist/{pinned/format.js → bot/pinned/pinned-message-format.js} +1 -2
  122. package/dist/{pinned/manager.js → bot/pinned/pinned-message-manager.js} +10 -10
  123. package/dist/bot/pinned/pinned-message-types.js +1 -0
  124. package/dist/{summary → bot/render}/markdown-to-telegram-v2.js +2 -2
  125. package/dist/bot/render/types.js +1 -0
  126. package/dist/bot/routers/command-router.js +73 -0
  127. package/dist/bot/routers/message-router.js +150 -0
  128. package/dist/bot/services/attach-presentation.js +40 -0
  129. package/dist/bot/services/event-subscription-service.js +842 -0
  130. package/dist/bot/services/project-switch-presentation.js +26 -0
  131. package/dist/cli.js +4 -4
  132. package/dist/config.js +9 -3
  133. package/dist/index.js +1 -1
  134. package/dist/opencode/ready-refresh.js +2 -2
  135. package/dist/{service → runtime/service}/manager.js +2 -2
  136. package/dist/runtime/service/types.js +1 -0
  137. package/package.json +1 -1
  138. package/dist/bot/commands/ls.js +0 -445
  139. package/dist/bot/commands/open.js +0 -319
  140. package/dist/bot/commands/worktree.js +0 -201
  141. package/dist/bot/handlers/permission.js +0 -235
  142. package/dist/bot/utils/browser-roots.js +0 -140
  143. package/dist/bot/utils/file-tree.js +0 -92
  144. package/dist/bot/utils/send-tts-response.js +0 -37
  145. package/dist/bot/utils/switch-project.js +0 -45
  146. package/dist/bot/utils/telegram-file-url.js +0 -11
  147. package/dist/interaction/busy.js +0 -8
  148. package/dist/interaction/cleanup.js +0 -32
  149. /package/dist/{scheduled-task/display.js → app/formatters/scheduled-task-display-formatter.js} +0 -0
  150. /package/dist/{bot/utils/abort-error-suppression.js → app/managers/abort-suppression-manager.js} +0 -0
  151. /package/dist/{external-input/suppression.js → app/managers/external-input-suppression-manager.js} +0 -0
  152. /package/dist/{scheduled-task/next-run.js → app/services/scheduled-task-next-run-service.js} +0 -0
  153. /package/dist/{git/worktree.js → app/services/worktree-service.js} +0 -0
  154. /package/dist/{agent/types.js → app/types/agent.js} +0 -0
  155. /package/dist/{interaction/types.js → app/types/interaction.js} +0 -0
  156. /package/dist/{model/types.js → app/types/model.js} +0 -0
  157. /package/dist/{keyboard/types.js → app/types/permission.js} +0 -0
  158. /package/dist/{permission/types.js → app/types/project.js} +0 -0
  159. /package/dist/{pinned/types.js → app/types/question.js} +0 -0
  160. /package/dist/{scheduled-task/types.js → app/types/scheduled-task.js} +0 -0
  161. /package/dist/{question/types.js → app/types/session.js} +0 -0
  162. /package/dist/{service/types.js → app/types/settings.js} +0 -0
  163. /package/dist/{telegram/render/types.js → app/types/summary.js} +0 -0
  164. /package/dist/{variant/types.js → app/types/variant.js} +0 -0
  165. /package/dist/bot/commands/{help.js → help-command.js} +0 -0
  166. /package/dist/bot/{utils → messages}/send-with-markdown-fallback.js +0 -0
  167. /package/dist/bot/{utils → messages}/telegram-text.js +0 -0
  168. /package/dist/bot/{utils → messages}/thinking-message.js +0 -0
  169. /package/dist/{telegram → bot}/render/block-fallback.js +0 -0
  170. /package/dist/{telegram → bot}/render/block-parser.js +0 -0
  171. /package/dist/{telegram → bot}/render/block-renderer.js +0 -0
  172. /package/dist/{telegram → bot}/render/chunker.js +0 -0
  173. /package/dist/{telegram → bot}/render/inline-renderer.js +0 -0
  174. /package/dist/{telegram → bot}/render/markdown-normalizer.js +0 -0
  175. /package/dist/{telegram → bot}/render/pipeline.js +0 -0
  176. /package/dist/{telegram → bot}/render/validator.js +0 -0
  177. /package/dist/bot/{utils/commands.js → routers/command-utils.js} +0 -0
  178. /package/dist/bot/{utils → streaming}/finalize-assistant-response.js +0 -0
  179. /package/dist/{service/runtime.js → runtime/service/env.js} +0 -0
@@ -0,0 +1,26 @@
1
+ import { keyboardManager } from "../keyboards/keyboard-manager.js";
2
+ import { createMainKeyboard } from "../keyboards/main-reply-keyboard.js";
3
+ import { pinnedMessageManager } from "../pinned/pinned-message-manager.js";
4
+ export function createProjectSwitchPresentation() {
5
+ return {
6
+ async clearPinnedMessage() {
7
+ await pinnedMessageManager.clear();
8
+ },
9
+ initializeKeyboard(ctx) {
10
+ if (ctx.chat) {
11
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
12
+ }
13
+ },
14
+ async refreshContextLimit() {
15
+ await pinnedMessageManager.refreshContextLimit();
16
+ return pinnedMessageManager.getContextLimit();
17
+ },
18
+ updateKeyboardContext(contextInfo) {
19
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
20
+ },
21
+ updateKeyboardAgent(agent) {
22
+ keyboardManager.updateAgent(agent);
23
+ },
24
+ createMainKeyboard,
25
+ };
26
+ }
package/dist/cli.js CHANGED
@@ -81,7 +81,7 @@ async function runStartCommand(mode, daemon) {
81
81
  const { ensureRuntimeConfigForStart } = await import("./runtime/bootstrap.js");
82
82
  await ensureRuntimeConfigForStart();
83
83
  if (daemon) {
84
- const { startBotDaemon } = await import("./service/manager.js");
84
+ const { startBotDaemon } = await import("./runtime/service/manager.js");
85
85
  const result = await startBotDaemon(modeResult.mode);
86
86
  const cleanupMessage = formatServiceCleanupMessage(result.cleanupReason);
87
87
  const runtimePaths = getRuntimePaths();
@@ -115,7 +115,7 @@ async function runStartCommand(mode, daemon) {
115
115
  }
116
116
  const { initializeLogger } = await import("./utils/logger.js");
117
117
  await initializeLogger();
118
- const { startBotApp } = await import("./app/start-bot-app.js");
118
+ const { startBotApp } = await import("./app/bootstrap/start-bot-app.js");
119
119
  await startBotApp();
120
120
  return EXIT_SUCCESS;
121
121
  }
@@ -129,7 +129,7 @@ async function runConfigCommand() {
129
129
  }
130
130
  async function runStatusCommand() {
131
131
  setRuntimeMode("installed");
132
- const { getBotServiceStatus } = await import("./service/manager.js");
132
+ const { getBotServiceStatus } = await import("./runtime/service/manager.js");
133
133
  const runtimePaths = getRuntimePaths();
134
134
  const status = await getBotServiceStatus();
135
135
  writeOptionalLine(formatServiceCleanupMessage(status.cleanupReason));
@@ -150,7 +150,7 @@ async function runStatusCommand() {
150
150
  }
151
151
  async function runStopCommand() {
152
152
  setRuntimeMode("installed");
153
- const { stopBotDaemon } = await import("./service/manager.js");
153
+ const { stopBotDaemon } = await import("./runtime/service/manager.js");
154
154
  const result = await stopBotDaemon();
155
155
  writeOptionalLine(formatServiceCleanupMessage(result.cleanupReason));
156
156
  if (!result.success) {
package/dist/config.js CHANGED
@@ -61,7 +61,7 @@ function getOptionalMessageFormatModeEnvVar(key, defaultValue) {
61
61
  }
62
62
  return defaultValue;
63
63
  }
64
- const VALID_TTS_PROVIDERS = ["openai", "google"];
64
+ const VALID_TTS_PROVIDERS = ["openai", "google", "elevenlabs"];
65
65
  function getOptionalTtsProviderEnvVar(key, defaultValue) {
66
66
  const value = getEnvVar(key, false);
67
67
  if (!value) {
@@ -121,6 +121,7 @@ export const config = {
121
121
  commandsListLimit: getOptionalPositiveIntEnvVar("COMMANDS_LIST_LIMIT", 10),
122
122
  taskLimit: getOptionalPositiveIntEnvVar("TASK_LIMIT", 10),
123
123
  scheduledTaskExecutionTimeoutMinutes: getOptionalPositiveIntEnvVar("SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES", 120),
124
+ scheduledTaskNotificationsSilent: getOptionalBooleanEnvVar("SCHEDULED_TASK_DISABLE_NOTIFICATION", false),
124
125
  responseStreamThrottleMs: getOptionalPositiveIntEnvVar("RESPONSE_STREAM_THROTTLE_MS", 1000),
125
126
  responseStreamingMode: getOptionalStreamingModeEnvVar("RESPONSE_STREAMING_MODE", "edit"),
126
127
  bashToolDisplayMaxLength: getOptionalPositiveIntEnvVar("BASH_TOOL_DISPLAY_MAX_LENGTH", 128),
@@ -146,12 +147,17 @@ export const config = {
146
147
  },
147
148
  tts: (() => {
148
149
  const provider = getOptionalTtsProviderEnvVar("TTS_PROVIDER", "openai");
149
- const defaultVoice = provider === "google" ? "en-US-Studio-O" : "alloy";
150
+ const defaultVoice = provider === "google"
151
+ ? "en-US-Studio-O"
152
+ : provider === "elevenlabs"
153
+ ? "21m00Tcm4TlvDq8ikWAM"
154
+ : "alloy";
155
+ const defaultModel = provider === "elevenlabs" ? "eleven_flash_v2_5" : "gpt-4o-mini-tts";
150
156
  return {
151
157
  apiUrl: getEnvVar("TTS_API_URL", false),
152
158
  apiKey: getEnvVar("TTS_API_KEY", false),
153
159
  provider,
154
- model: getEnvVar("TTS_MODEL", false) || "gpt-4o-mini-tts",
160
+ model: getEnvVar("TTS_MODEL", false) || defaultModel,
155
161
  voice: getEnvVar("TTS_VOICE", false) || defaultVoice,
156
162
  };
157
163
  })(),
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ async function main() {
14
14
  setRuntimeMode(modeResult.mode);
15
15
  const { initializeLogger } = await import("./utils/logger.js");
16
16
  await initializeLogger();
17
- const { startBotApp } = await import("./app/start-bot-app.js");
17
+ const { startBotApp } = await import("./app/bootstrap/start-bot-app.js");
18
18
  await startBotApp();
19
19
  }
20
20
  void main().catch((error) => {
@@ -1,5 +1,5 @@
1
- import { reconcileStoredModelSelection } from "../model/manager.js";
2
- import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
1
+ import { reconcileStoredModelSelection } from "../app/services/model-selection-service.js";
2
+ import { warmupSessionDirectoryCache } from "../app/services/session-cache-service.js";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { opencodeClient } from "./client.js";
5
5
  import { opencodeReadyLifecycle } from "./ready-lifecycle.js";
@@ -3,8 +3,8 @@ import fsPromises from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { exec, spawn } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
- import { getRuntimePaths } from "../runtime/paths.js";
7
- import { buildServiceChildEnv } from "./runtime.js";
6
+ import { getRuntimePaths } from "../paths.js";
7
+ import { buildServiceChildEnv } from "./env.js";
8
8
  const execAsync = promisify(exec);
9
9
  const SERVICE_STATE_FILE_NAME = "bot-service.json";
10
10
  const PROCESS_EXIT_POLL_MS = 100;
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,445 +0,0 @@
1
- import { InlineKeyboard } from "grammy";
2
- import path from "node:path";
3
- import { promises as fs } from "node:fs";
4
- import os from "node:os";
5
- import { appendInlineMenuCancelButton, clearActiveInlineMenu, ensureActiveInlineMenu, } from "../handlers/inline-menu.js";
6
- import { interactionManager } from "../../interaction/manager.js";
7
- import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
8
- import { getCurrentProject } from "../../settings/manager.js";
9
- import { sendDownloadedFile } from "../utils/send-downloaded-file.js";
10
- import { formatFileSize } from "../utils/file-download.js";
11
- import { logger } from "../../utils/logger.js";
12
- import { t } from "../../i18n/index.js";
13
- const CALLBACK_PREFIX = "ls:";
14
- const CALLBACK_NAV_PREFIX = "ls:nav:";
15
- const CALLBACK_FILE_PREFIX = "ls:file:";
16
- const CALLBACK_DOWNLOAD_PREFIX = "ls:download:";
17
- const CALLBACK_BACK_PREFIX = "ls:back:";
18
- const CALLBACK_PAGE_PREFIX = "ls:pg:";
19
- const PAGE_SEPARATOR = "|";
20
- const MAX_ENTRIES_PER_PAGE = 8;
21
- const MAX_BUTTON_LABEL_LENGTH = 64;
22
- const sessionDirectories = new Map();
23
- const pathIndex = new Map();
24
- let pathCounter = 0;
25
- function escapeHtml(text) {
26
- return text
27
- .replace(/&/g, "&")
28
- .replace(/</g, "&lt;")
29
- .replace(/>/g, "&gt;")
30
- .replace(/\"/g, "&quot;")
31
- .replace(/'/g, "&#39;");
32
- }
33
- function truncateLabel(label, maxLen = MAX_BUTTON_LABEL_LENGTH) {
34
- if (label.length <= maxLen) {
35
- return label;
36
- }
37
- return `${label.slice(0, Math.max(0, maxLen - 3))}...`;
38
- }
39
- function pathToDisplayPath(absolutePath) {
40
- const home = os.homedir();
41
- if (absolutePath === home) {
42
- return "~";
43
- }
44
- if (absolutePath.startsWith(home + path.sep)) {
45
- return `~${absolutePath.slice(home.length)}`;
46
- }
47
- return absolutePath;
48
- }
49
- function usesWindowsPath(filePath) {
50
- return /^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith("\\\\");
51
- }
52
- function getPathApi(filePath) {
53
- return usesWindowsPath(filePath) ? path.win32 : path.posix;
54
- }
55
- function joinPath(parentPath, childName) {
56
- return getPathApi(parentPath).join(parentPath, childName);
57
- }
58
- function getBaseName(filePath) {
59
- return getPathApi(filePath).basename(filePath);
60
- }
61
- function getParentPath(filePath) {
62
- return getPathApi(filePath).dirname(filePath);
63
- }
64
- function getRootPath(filePath) {
65
- return getPathApi(filePath).parse(filePath).root;
66
- }
67
- function isSamePath(leftPath, rightPath) {
68
- return getPathApi(rightPath).relative(rightPath, leftPath) === "";
69
- }
70
- function buildEntryLabel(entry) {
71
- return `${entry.type === "directory" ? "📁" : "📄"} ${entry.name}`;
72
- }
73
- function isPathWithinDirectory(targetPath, directoryPath) {
74
- const pathApi = getPathApi(directoryPath);
75
- const relativePath = pathApi.relative(directoryPath, targetPath);
76
- return relativePath === "" || (!relativePath.startsWith("..") && !pathApi.isAbsolute(relativePath));
77
- }
78
- function getProjectRoot() {
79
- return getCurrentProject()?.worktree ?? null;
80
- }
81
- function isWithinProjectRoot(targetPath) {
82
- const projectRoot = getProjectRoot();
83
- return projectRoot !== null && isPathWithinDirectory(targetPath, projectRoot);
84
- }
85
- function isProjectRoot(targetPath) {
86
- const projectRoot = getProjectRoot();
87
- return projectRoot !== null && isSamePath(targetPath, projectRoot);
88
- }
89
- function buildLsHeader(displayPath, totalCount, page, totalPages) {
90
- let header = `📁 ${t("ls.header")}\n<code>${escapeHtml(displayPath)}</code>`;
91
- if (totalPages > 1) {
92
- header += `\n(${page + 1}/${totalPages})`;
93
- }
94
- header += `\n${t("ls.total", { count: totalCount })}`;
95
- return header;
96
- }
97
- function buildFileDetailsText(fileDetails) {
98
- return (`📄 ${t("ls.file.header")}\n<code>${escapeHtml(fileDetails.name)}</code>\n` +
99
- `${t("commands.download.size")}: ${formatFileSize(fileDetails.size)}\n` +
100
- `${t("commands.download.modified")}: ${fileDetails.modified.toLocaleDateString()}`);
101
- }
102
- function encodePathForCallback(prefix, fullPath, reserveBytes = 0) {
103
- const naive = `${prefix}${fullPath}`;
104
- if (Buffer.byteLength(naive, "utf-8") + reserveBytes <= 64) {
105
- return naive;
106
- }
107
- const key = `#${pathCounter++}`;
108
- pathIndex.set(key, fullPath);
109
- return `${prefix}${key}`;
110
- }
111
- function decodePathFromCallback(prefix, data) {
112
- if (!data.startsWith(prefix)) {
113
- return null;
114
- }
115
- const raw = data.slice(prefix.length);
116
- if (raw.startsWith("#")) {
117
- return pathIndex.get(raw) ?? null;
118
- }
119
- return raw;
120
- }
121
- function encodePathWithPageCallback(prefix, fullPath, page) {
122
- const pageSuffix = `${PAGE_SEPARATOR}${page}`;
123
- const reserveBytes = Buffer.byteLength(pageSuffix, "utf-8");
124
- const pathRef = encodePathForCallback(prefix, fullPath, reserveBytes);
125
- return `${pathRef}${pageSuffix}`;
126
- }
127
- function decodePathWithPageCallback(data, prefix) {
128
- if (!data.startsWith(prefix)) {
129
- return null;
130
- }
131
- const payload = data.slice(prefix.length);
132
- const separatorIndex = payload.lastIndexOf(PAGE_SEPARATOR);
133
- if (separatorIndex < 0) {
134
- return null;
135
- }
136
- const pathRef = payload.slice(0, separatorIndex);
137
- const page = Number.parseInt(payload.slice(separatorIndex + 1), 10);
138
- if (Number.isNaN(page)) {
139
- return null;
140
- }
141
- const resolvedPath = pathRef.startsWith("#") ? (pathIndex.get(pathRef) ?? null) : pathRef;
142
- if (resolvedPath === null) {
143
- return null;
144
- }
145
- return { path: resolvedPath, page };
146
- }
147
- function encodePaginationCallback(currentPath, page) {
148
- return encodePathWithPageCallback(CALLBACK_PAGE_PREFIX, currentPath, page);
149
- }
150
- function decodePaginationCallback(data) {
151
- return decodePathWithPageCallback(data, CALLBACK_PAGE_PREFIX);
152
- }
153
- function encodeFileCallback(fullPath, page) {
154
- return encodePathWithPageCallback(CALLBACK_FILE_PREFIX, fullPath, page);
155
- }
156
- function decodeFileCallback(data) {
157
- return decodePathWithPageCallback(data, CALLBACK_FILE_PREFIX);
158
- }
159
- function encodeBackCallback(directoryPath, page) {
160
- return encodePathWithPageCallback(CALLBACK_BACK_PREFIX, directoryPath, page);
161
- }
162
- function decodeBackCallback(data) {
163
- return decodePathWithPageCallback(data, CALLBACK_BACK_PREFIX);
164
- }
165
- async function scanDirectory(dirPath, page = 0) {
166
- try {
167
- if (!isWithinProjectRoot(dirPath)) {
168
- return { error: t("ls.access_denied") };
169
- }
170
- const dirEntries = await fs.readdir(dirPath, { withFileTypes: true });
171
- const entries = dirEntries
172
- .map((entry) => ({
173
- name: entry.name,
174
- fullPath: joinPath(dirPath, entry.name),
175
- type: entry.isDirectory() ? "directory" : "file",
176
- }))
177
- .sort((left, right) => {
178
- if (left.type !== right.type) {
179
- return left.type === "directory" ? -1 : 1;
180
- }
181
- return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
182
- });
183
- const totalPages = Math.max(1, Math.ceil(entries.length / MAX_ENTRIES_PER_PAGE));
184
- const safePage = Math.max(0, Math.min(page, totalPages - 1));
185
- const startIndex = safePage * MAX_ENTRIES_PER_PAGE;
186
- return {
187
- entries: entries.slice(startIndex, startIndex + MAX_ENTRIES_PER_PAGE),
188
- totalCount: entries.length,
189
- currentPath: dirPath,
190
- displayPath: pathToDisplayPath(dirPath),
191
- hasParent: dirPath !== getRootPath(dirPath),
192
- page: safePage,
193
- };
194
- }
195
- catch (error) {
196
- return {
197
- error: `${t("ls.scan_error")}: ${error instanceof Error ? error.message : "Unknown error"}`,
198
- };
199
- }
200
- }
201
- function buildBrowseKeyboard(entries, currentPath, hasParent, page, totalCount) {
202
- const keyboard = new InlineKeyboard();
203
- const totalPages = Math.max(1, Math.ceil(totalCount / MAX_ENTRIES_PER_PAGE));
204
- for (const entry of entries) {
205
- const label = truncateLabel(buildEntryLabel(entry));
206
- const callbackData = entry.type === "directory"
207
- ? encodePathForCallback(CALLBACK_NAV_PREFIX, entry.fullPath)
208
- : encodeFileCallback(entry.fullPath, page);
209
- keyboard.text(label, callbackData).row();
210
- }
211
- if (hasParent && !isProjectRoot(currentPath)) {
212
- keyboard.text(t("open.back"), encodePathForCallback(CALLBACK_NAV_PREFIX, getParentPath(currentPath))).row();
213
- }
214
- if (totalPages > 1) {
215
- if (page > 0) {
216
- keyboard.text(t("open.prev_page"), encodePaginationCallback(currentPath, page - 1));
217
- }
218
- if (page < totalPages - 1) {
219
- keyboard.text(t("open.next_page"), encodePaginationCallback(currentPath, page + 1));
220
- }
221
- keyboard.row();
222
- }
223
- appendInlineMenuCancelButton(keyboard, "ls");
224
- return keyboard;
225
- }
226
- function buildFileDetailsKeyboard(filePath, page) {
227
- const keyboard = new InlineKeyboard();
228
- const parentPath = getParentPath(filePath);
229
- keyboard.text(t("ls.file.download"), encodePathForCallback(CALLBACK_DOWNLOAD_PREFIX, filePath));
230
- keyboard.text(t("ls.file.back"), encodeBackCallback(parentPath, page));
231
- keyboard.row();
232
- appendInlineMenuCancelButton(keyboard, "ls");
233
- return keyboard;
234
- }
235
- function hasBrowseActions(currentPath, hasParent, totalCount) {
236
- if (totalCount > 0) {
237
- return true;
238
- }
239
- return hasParent && !isProjectRoot(currentPath);
240
- }
241
- async function renderBrowseView(dirPath, page = 0) {
242
- const result = await scanDirectory(dirPath, page);
243
- if ("error" in result) {
244
- return result;
245
- }
246
- const totalPages = Math.max(1, Math.ceil(result.totalCount / MAX_ENTRIES_PER_PAGE));
247
- return {
248
- text: buildLsHeader(result.displayPath, result.totalCount, result.page, totalPages),
249
- hasActions: hasBrowseActions(result.currentPath, result.hasParent, result.totalCount),
250
- keyboard: buildBrowseKeyboard(result.entries, result.currentPath, result.hasParent, result.page, result.totalCount),
251
- };
252
- }
253
- async function getFileDetails(filePath) {
254
- try {
255
- if (!isWithinProjectRoot(filePath)) {
256
- return { error: t("ls.access_denied") };
257
- }
258
- const stat = await fs.stat(filePath);
259
- if (!stat.isFile()) {
260
- return { error: t("commands.download.not_file") };
261
- }
262
- return {
263
- name: getBaseName(filePath),
264
- fullPath: filePath,
265
- size: stat.size,
266
- modified: stat.mtime,
267
- };
268
- }
269
- catch (error) {
270
- return {
271
- error: `${t("ls.scan_error")}: ${error instanceof Error ? error.message : "Unknown error"}`,
272
- };
273
- }
274
- }
275
- async function renderFileDetailsView(filePath, page) {
276
- const fileDetails = await getFileDetails(filePath);
277
- if ("error" in fileDetails) {
278
- return fileDetails;
279
- }
280
- return {
281
- text: buildFileDetailsText(fileDetails),
282
- keyboard: buildFileDetailsKeyboard(fileDetails.fullPath, page),
283
- };
284
- }
285
- function resolveInitialDirectory(userId) {
286
- const currentProject = getProjectRoot();
287
- if (!currentProject) {
288
- return null;
289
- }
290
- if (userId) {
291
- const cachedDirectory = sessionDirectories.get(userId);
292
- if (cachedDirectory && isPathWithinDirectory(cachedDirectory, currentProject)) {
293
- return cachedDirectory;
294
- }
295
- }
296
- return currentProject;
297
- }
298
- export function clearLsPathIndex() {
299
- pathIndex.clear();
300
- pathCounter = 0;
301
- }
302
- export function clearSessionDirectories() {
303
- sessionDirectories.clear();
304
- }
305
- export async function lsCommand(ctx) {
306
- if (isForegroundBusy()) {
307
- await replyBusyBlocked(ctx);
308
- return;
309
- }
310
- clearLsPathIndex();
311
- const projectRoot = getProjectRoot();
312
- if (!projectRoot) {
313
- await ctx.reply(t("bot.project_not_selected"));
314
- return;
315
- }
316
- const args = typeof ctx.match === "string" ? ctx.match.trim() : undefined;
317
- const targetDir = args || resolveInitialDirectory(ctx.from?.id);
318
- if (!targetDir) {
319
- await ctx.reply(t("bot.project_not_selected"));
320
- return;
321
- }
322
- if (!isWithinProjectRoot(targetDir)) {
323
- await ctx.reply(`❌ ${t("ls.access_denied")}`);
324
- return;
325
- }
326
- const view = await renderBrowseView(targetDir);
327
- if ("error" in view) {
328
- await ctx.reply(`❌ ${view.error}`);
329
- return;
330
- }
331
- if (ctx.from) {
332
- sessionDirectories.set(ctx.from.id, targetDir);
333
- }
334
- if (!view.hasActions) {
335
- await ctx.reply(view.text, { parse_mode: "HTML" });
336
- return;
337
- }
338
- const message = await ctx.reply(view.text, { parse_mode: "HTML", reply_markup: view.keyboard });
339
- interactionManager.start({
340
- kind: "inline",
341
- expectedInput: "callback",
342
- metadata: {
343
- menuKind: "ls",
344
- messageId: message.message_id,
345
- },
346
- });
347
- }
348
- async function navigateTo(ctx, dirPath, page = 0) {
349
- const view = await renderBrowseView(dirPath, page);
350
- if ("error" in view) {
351
- await ctx.answerCallbackQuery({ text: view.error });
352
- return;
353
- }
354
- if (ctx.from) {
355
- sessionDirectories.set(ctx.from.id, dirPath);
356
- }
357
- await ctx.answerCallbackQuery();
358
- await ctx.editMessageText(view.text, { parse_mode: "HTML", reply_markup: view.keyboard });
359
- }
360
- async function showFileDetails(ctx, filePath, page) {
361
- const view = await renderFileDetailsView(filePath, page);
362
- if ("error" in view) {
363
- await ctx.answerCallbackQuery({ text: view.error });
364
- return;
365
- }
366
- await ctx.answerCallbackQuery();
367
- await ctx.editMessageText(view.text, { parse_mode: "HTML", reply_markup: view.keyboard });
368
- }
369
- async function downloadFileAndClose(ctx, filePath) {
370
- await ctx.answerCallbackQuery({ text: t("commands.download.downloading") });
371
- const downloaded = await sendDownloadedFile(ctx, filePath, { announce: false });
372
- if (!downloaded) {
373
- return;
374
- }
375
- clearActiveInlineMenu("ls_downloaded");
376
- clearLsPathIndex();
377
- await ctx.deleteMessage().catch(() => { });
378
- }
379
- export async function handleLsCallback(ctx) {
380
- const data = ctx.callbackQuery?.data;
381
- if (!data || !data.startsWith(CALLBACK_PREFIX)) {
382
- return false;
383
- }
384
- if (isForegroundBusy()) {
385
- await replyBusyBlocked(ctx);
386
- return true;
387
- }
388
- const isActiveMenu = await ensureActiveInlineMenu(ctx, "ls");
389
- if (!isActiveMenu) {
390
- return true;
391
- }
392
- try {
393
- const navPath = decodePathFromCallback(CALLBACK_NAV_PREFIX, data);
394
- if (navPath !== null) {
395
- if (!isWithinProjectRoot(navPath)) {
396
- await ctx.answerCallbackQuery({ text: t("ls.access_denied") });
397
- return true;
398
- }
399
- await navigateTo(ctx, navPath);
400
- return true;
401
- }
402
- const pageInfo = decodePaginationCallback(data);
403
- if (pageInfo !== null) {
404
- if (!isWithinProjectRoot(pageInfo.path)) {
405
- await ctx.answerCallbackQuery({ text: t("ls.access_denied") });
406
- return true;
407
- }
408
- await navigateTo(ctx, pageInfo.path, pageInfo.page);
409
- return true;
410
- }
411
- const fileInfo = decodeFileCallback(data);
412
- if (fileInfo !== null) {
413
- if (!isWithinProjectRoot(fileInfo.path)) {
414
- await ctx.answerCallbackQuery({ text: t("ls.access_denied") });
415
- return true;
416
- }
417
- await showFileDetails(ctx, fileInfo.path, fileInfo.page);
418
- return true;
419
- }
420
- const downloadPath = decodePathFromCallback(CALLBACK_DOWNLOAD_PREFIX, data);
421
- if (downloadPath !== null) {
422
- if (!isWithinProjectRoot(downloadPath)) {
423
- await ctx.answerCallbackQuery({ text: t("ls.access_denied") });
424
- return true;
425
- }
426
- await downloadFileAndClose(ctx, downloadPath);
427
- return true;
428
- }
429
- const backInfo = decodeBackCallback(data);
430
- if (backInfo !== null) {
431
- if (!isWithinProjectRoot(backInfo.path)) {
432
- await ctx.answerCallbackQuery({ text: t("ls.access_denied") });
433
- return true;
434
- }
435
- await navigateTo(ctx, backInfo.path, backInfo.page);
436
- return true;
437
- }
438
- return false;
439
- }
440
- catch (error) {
441
- logger.error("[Ls] Error handling callback:", error);
442
- await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
443
- return true;
444
- }
445
- }