@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
@@ -1,7 +1,9 @@
1
1
  import { t } from "../../i18n/index.js";
2
- import { escapePlainTextForTelegramMarkdownV2 } from "../../summary/formatter.js";
3
- import { sendBotText } from "./telegram-text.js";
4
2
  const EXTERNAL_USER_INPUT_MAX_DISPLAY_LENGTH = 2000;
3
+ const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
4
+ function escapePlainTextForTelegramMarkdownV2(text) {
5
+ return text.replace(MARKDOWN_V2_RESERVED_CHARS, "\\$1");
6
+ }
5
7
  function normalizeExternalUserInputText(text) {
6
8
  return text.replace(/\r\n/g, "\n").trim();
7
9
  }
@@ -35,20 +37,3 @@ export function buildExternalUserInputNotification(text) {
35
37
  rawFallbackText: `${title}\n\n${buildQuotedPlainText(displayText)}`,
36
38
  };
37
39
  }
38
- export async function deliverExternalUserInputNotification({ api, chatId, currentSessionId, sessionId, text, consumeSuppressedInput, }) {
39
- const notification = buildExternalUserInputNotification(text);
40
- if (!notification || currentSessionId !== sessionId) {
41
- return false;
42
- }
43
- if (consumeSuppressedInput(sessionId, text)) {
44
- return false;
45
- }
46
- await sendBotText({
47
- api,
48
- chatId,
49
- text: notification.text,
50
- rawFallbackText: notification.rawFallbackText,
51
- format: "markdown_v2",
52
- });
53
- return true;
54
- }
@@ -0,0 +1,265 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { realpath } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { t } from "../../i18n/index.js";
6
+ import { getCurrentProject } from "../stores/settings-store.js";
7
+ import { logger } from "../../utils/logger.js";
8
+ export const MAX_ENTRIES_PER_PAGE = 8;
9
+ let resolvedRoots = null;
10
+ export function getHomeDirectory() {
11
+ return os.homedir();
12
+ }
13
+ export function pathToDisplayPath(absolutePath) {
14
+ const home = getHomeDirectory();
15
+ if (absolutePath === home) {
16
+ return "~";
17
+ }
18
+ if (absolutePath.startsWith(home + path.sep)) {
19
+ return "~" + absolutePath.slice(home.length);
20
+ }
21
+ return absolutePath;
22
+ }
23
+ export async function scanDirectory(dirPath, page = 0) {
24
+ try {
25
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
26
+ const subdirs = [];
27
+ for (const entry of entries) {
28
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
29
+ subdirs.push({
30
+ name: entry.name,
31
+ fullPath: path.join(dirPath, entry.name),
32
+ });
33
+ }
34
+ }
35
+ subdirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
36
+ const parentPath = path.dirname(dirPath);
37
+ const hasParent = dirPath !== path.parse(dirPath).root;
38
+ const totalPages = Math.max(1, Math.ceil(subdirs.length / MAX_ENTRIES_PER_PAGE));
39
+ const safePage = Math.max(0, Math.min(page, totalPages - 1));
40
+ const start = safePage * MAX_ENTRIES_PER_PAGE;
41
+ return {
42
+ entries: subdirs.slice(start, start + MAX_ENTRIES_PER_PAGE),
43
+ totalCount: subdirs.length,
44
+ page: safePage,
45
+ currentPath: dirPath,
46
+ displayPath: pathToDisplayPath(dirPath),
47
+ hasParent,
48
+ parentPath: hasParent ? parentPath : null,
49
+ };
50
+ }
51
+ catch (error) {
52
+ if (error instanceof Error && "code" in error) {
53
+ const code = error.code;
54
+ if (code === "ENOENT" || code === "ELOOP") {
55
+ return { error: `Directory not found: ${dirPath}`, code: "ENOENT" };
56
+ }
57
+ if (code === "EACCES" || code === "EPERM") {
58
+ return { error: `Permission denied: ${dirPath}`, code: "EACCES" };
59
+ }
60
+ if (code === "ENOTDIR") {
61
+ return { error: `Not a directory: ${dirPath}`, code: "ENOTDIR" };
62
+ }
63
+ }
64
+ return {
65
+ error: error instanceof Error ? error.message : "Unknown error",
66
+ code: "UNKNOWN",
67
+ };
68
+ }
69
+ }
70
+ export function buildEntryLabel(entry) {
71
+ return `📁 ${entry.name}`;
72
+ }
73
+ export function buildTreeHeader(displayPath, totalCount, page, totalPages) {
74
+ let header = `📂 ${displayPath}`;
75
+ if (totalPages > 1) {
76
+ header += ` (${page + 1}/${totalPages})`;
77
+ }
78
+ if (totalCount === 0) {
79
+ header += `\n${t("open.no_subfolders")}`;
80
+ }
81
+ else if (totalCount === 1) {
82
+ header += `\n${t("open.subfolder_count", { count: String(totalCount) })}`;
83
+ }
84
+ else {
85
+ header += `\n${t("open.subfolders_count", { count: String(totalCount) })}`;
86
+ }
87
+ return header;
88
+ }
89
+ export function isScanError(result) {
90
+ return "error" in result;
91
+ }
92
+ function isWindows() {
93
+ return process.platform === "win32";
94
+ }
95
+ function expandTilde(p) {
96
+ if (p === "~") {
97
+ return os.homedir();
98
+ }
99
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
100
+ return path.join(os.homedir(), p.slice(2));
101
+ }
102
+ return p;
103
+ }
104
+ function resolveConfiguredPath(p) {
105
+ return path.resolve(expandTilde(p));
106
+ }
107
+ function normalizePath(p) {
108
+ const resolved = resolveConfiguredPath(p);
109
+ return isWindows() ? resolved.toLowerCase() : resolved;
110
+ }
111
+ export function initBrowserRoots(raw) {
112
+ if (!raw || raw.trim() === "") {
113
+ resolvedRoots = [resolveConfiguredPath(os.homedir())];
114
+ logger.debug(`[BrowserRoots] No OPEN_BROWSER_ROOTS configured, defaulting to home: ${resolvedRoots[0]}`);
115
+ return;
116
+ }
117
+ const entries = raw
118
+ .split(",")
119
+ .map((s) => s.trim())
120
+ .filter((s) => s.length > 0);
121
+ const roots = entries.map((entry) => resolveConfiguredPath(entry));
122
+ if (roots.length === 0) {
123
+ resolvedRoots = [resolveConfiguredPath(os.homedir())];
124
+ logger.warn("[BrowserRoots] All configured roots were invalid, falling back to home directory");
125
+ }
126
+ else {
127
+ resolvedRoots = roots;
128
+ logger.info(`[BrowserRoots] Configured roots: ${roots.join(", ")}`);
129
+ }
130
+ }
131
+ export function getBrowserRoots() {
132
+ if (resolvedRoots === null) {
133
+ initBrowserRoots(process.env.OPEN_BROWSER_ROOTS);
134
+ }
135
+ return resolvedRoots;
136
+ }
137
+ export function getBrowserRootPaths() {
138
+ return getBrowserRoots();
139
+ }
140
+ export function isWithinAllowedRoot(targetPath) {
141
+ const normalizedTarget = normalizePath(targetPath);
142
+ for (const root of getBrowserRoots()) {
143
+ const normalizedRoot = normalizePath(root);
144
+ if (normalizedTarget === normalizedRoot) {
145
+ return true;
146
+ }
147
+ if (normalizedTarget.startsWith(normalizedRoot + "/") ||
148
+ normalizedTarget.startsWith(normalizedRoot + "\\")) {
149
+ return true;
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+ export async function isWithinAllowedRootSafe(targetPath) {
155
+ let resolved = targetPath;
156
+ try {
157
+ resolved = await realpath(targetPath);
158
+ }
159
+ catch {
160
+ // Path doesn't exist yet or can't be resolved; use the original value.
161
+ }
162
+ return isWithinAllowedRoot(resolved);
163
+ }
164
+ export function isAllowedRoot(targetPath) {
165
+ const normalizedTarget = normalizePath(targetPath);
166
+ return getBrowserRoots().some((root) => normalizePath(root) === normalizedTarget);
167
+ }
168
+ export function __resetBrowserRootsForTests() {
169
+ resolvedRoots = null;
170
+ }
171
+ function usesWindowsPath(filePath) {
172
+ return /^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith("\\\\");
173
+ }
174
+ function getPathApi(filePath) {
175
+ return usesWindowsPath(filePath) ? path.win32 : path.posix;
176
+ }
177
+ export function joinPath(parentPath, childName) {
178
+ return getPathApi(parentPath).join(parentPath, childName);
179
+ }
180
+ export function getBaseName(filePath) {
181
+ return getPathApi(filePath).basename(filePath);
182
+ }
183
+ export function getParentPath(filePath) {
184
+ return getPathApi(filePath).dirname(filePath);
185
+ }
186
+ function getRootPath(filePath) {
187
+ return getPathApi(filePath).parse(filePath).root;
188
+ }
189
+ function isSamePath(leftPath, rightPath) {
190
+ return getPathApi(rightPath).relative(rightPath, leftPath) === "";
191
+ }
192
+ export function isPathWithinDirectory(targetPath, directoryPath) {
193
+ const pathApi = getPathApi(directoryPath);
194
+ const relativePath = pathApi.relative(directoryPath, targetPath);
195
+ return relativePath === "" || (!relativePath.startsWith("..") && !pathApi.isAbsolute(relativePath));
196
+ }
197
+ export function getProjectRoot() {
198
+ return getCurrentProject()?.worktree ?? null;
199
+ }
200
+ export function isWithinProjectRoot(targetPath) {
201
+ const projectRoot = getProjectRoot();
202
+ return projectRoot !== null && isPathWithinDirectory(targetPath, projectRoot);
203
+ }
204
+ export function isProjectRoot(targetPath) {
205
+ const projectRoot = getProjectRoot();
206
+ return projectRoot !== null && isSamePath(targetPath, projectRoot);
207
+ }
208
+ export async function scanLsDirectory(dirPath, page = 0) {
209
+ try {
210
+ if (!isWithinProjectRoot(dirPath)) {
211
+ return { error: t("ls.access_denied") };
212
+ }
213
+ const dirEntries = await fs.readdir(dirPath, { withFileTypes: true });
214
+ const entries = dirEntries
215
+ .map((entry) => ({
216
+ name: entry.name,
217
+ fullPath: joinPath(dirPath, entry.name),
218
+ type: entry.isDirectory() ? "directory" : "file",
219
+ }))
220
+ .sort((left, right) => {
221
+ if (left.type !== right.type) {
222
+ return left.type === "directory" ? -1 : 1;
223
+ }
224
+ return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
225
+ });
226
+ const totalPages = Math.max(1, Math.ceil(entries.length / MAX_ENTRIES_PER_PAGE));
227
+ const safePage = Math.max(0, Math.min(page, totalPages - 1));
228
+ const startIndex = safePage * MAX_ENTRIES_PER_PAGE;
229
+ return {
230
+ entries: entries.slice(startIndex, startIndex + MAX_ENTRIES_PER_PAGE),
231
+ totalCount: entries.length,
232
+ currentPath: dirPath,
233
+ displayPath: pathToDisplayPath(dirPath),
234
+ hasParent: dirPath !== getRootPath(dirPath),
235
+ page: safePage,
236
+ };
237
+ }
238
+ catch (error) {
239
+ return {
240
+ error: `${t("ls.scan_error")}: ${error instanceof Error ? error.message : "Unknown error"}`,
241
+ };
242
+ }
243
+ }
244
+ export async function getFileDetails(filePath) {
245
+ try {
246
+ if (!isWithinProjectRoot(filePath)) {
247
+ return { error: t("ls.access_denied") };
248
+ }
249
+ const stat = await fs.stat(filePath);
250
+ if (!stat.isFile()) {
251
+ return { error: t("commands.download.not_file") };
252
+ }
253
+ return {
254
+ name: getBaseName(filePath),
255
+ fullPath: filePath,
256
+ size: stat.size,
257
+ modified: stat.mtime,
258
+ };
259
+ }
260
+ catch (error) {
261
+ return {
262
+ error: `${t("ls.scan_error")}: ${error instanceof Error ? error.message : "Unknown error"}`,
263
+ };
264
+ }
265
+ }
@@ -1,16 +1,19 @@
1
- // @ts-expect-error node-fetch v2 ships no TS types and we avoid adding @types/node-fetch
1
+ // @ts-expect-error - node-fetch v2 ships no TS types and we avoid adding @types/node-fetch
2
2
  import nodeFetch from "node-fetch";
3
3
  import { Agent as HttpsAgent } from "https";
4
- import { logger } from "../../utils/logger.js";
5
4
  import { config } from "../../config.js";
6
- import { buildTelegramFileUrl } from "./telegram-file-url.js";
7
- const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB Telegram limit
8
- /**
9
- * Download a photo from Telegram servers
10
- * @param api Grammy API instance
11
- * @param fileId Telegram file_id
12
- * @returns Downloaded photo buffer and path
13
- */
5
+ import { logger } from "../../utils/logger.js";
6
+ const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
7
+ const DEFAULT_TELEGRAM_API_ROOT = "https://api.telegram.org";
8
+ function telegramFileUrlBase() {
9
+ const apiRoot = config.telegram.apiRoot
10
+ ? config.telegram.apiRoot.replace(/\/+$/, "")
11
+ : DEFAULT_TELEGRAM_API_ROOT;
12
+ return `${apiRoot}/file/bot`;
13
+ }
14
+ export function buildTelegramFileUrl(filePath) {
15
+ return `${telegramFileUrlBase()}${config.telegram.token}/${filePath}`;
16
+ }
14
17
  export async function downloadTelegramFile(api, fileId) {
15
18
  logger.debug(`[FileDownload] Getting file info for fileId=${fileId}`);
16
19
  const file = await api.getFile(fileId);
@@ -24,7 +27,6 @@ export async function downloadTelegramFile(api, fileId) {
24
27
  const fileUrl = buildTelegramFileUrl(file.file_path);
25
28
  logger.debug(`[FileDownload] Downloading from ${fileUrl.replace(config.telegram.token, "***")}`);
26
29
  const fetchOptions = {};
27
- // Use proxy if configured
28
30
  if (config.telegram.proxyUrl) {
29
31
  const { HttpsProxyAgent } = await import("https-proxy-agent");
30
32
  fetchOptions.agent = new HttpsProxyAgent(config.telegram.proxyUrl);
@@ -32,7 +34,6 @@ export async function downloadTelegramFile(api, fileId) {
32
34
  else if (config.telegram.forceIpv4) {
33
35
  fetchOptions.agent = new HttpsAgent({ family: 4, keepAlive: true });
34
36
  }
35
- // Send shared secret when custom API root expects it
36
37
  if (config.telegram.proxySecret) {
37
38
  fetchOptions.headers = {
38
39
  ...fetchOptions.headers,
@@ -51,32 +52,17 @@ export async function downloadTelegramFile(api, fileId) {
51
52
  filePath: file.file_path,
52
53
  };
53
54
  }
54
- /**
55
- * Convert buffer to base64 data URI
56
- * @param buffer File buffer
57
- * @param mimeType MIME type (e.g., "image/jpeg")
58
- * @returns Data URI string
59
- */
60
55
  export function toDataUri(buffer, mimeType) {
61
56
  const base64 = buffer.toString("base64");
62
57
  return `data:${mimeType};base64,${base64}`;
63
58
  }
64
- /**
65
- * Check if photo size is within limits
66
- * @param fileSize Photo size in bytes
67
- * @param maxSizeKb Maximum size in KB (from config)
68
- * @returns true if within limit
69
- */
70
59
  export function isFileSizeAllowed(fileSize, maxSizeKb) {
71
60
  if (!fileSize) {
72
- return true; // Unknown size, allow (will be checked on download)
61
+ return true;
73
62
  }
74
63
  const maxBytes = maxSizeKb * 1024;
75
64
  return fileSize <= maxBytes;
76
65
  }
77
- /**
78
- * Get human-readable photo size
79
- */
80
66
  export function formatFileSize(bytes) {
81
67
  if (bytes < 1024) {
82
68
  return `${bytes}B`;
@@ -0,0 +1,79 @@
1
+ import { opencodeClient } from "../../opencode/client.js";
2
+ function normalizeDirectoryForMcpApi(directory) {
3
+ return directory.replace(/\\/g, "/");
4
+ }
5
+ function parseMcpCatalogServers(value) {
6
+ if (!value || typeof value !== "object") {
7
+ return null;
8
+ }
9
+ if (Array.isArray(value)) {
10
+ const servers = [];
11
+ for (const item of value) {
12
+ if (!item || typeof item !== "object") {
13
+ return null;
14
+ }
15
+ const name = item.name;
16
+ const status = item.status;
17
+ if (typeof name !== "string" || !status || typeof status !== "object") {
18
+ return null;
19
+ }
20
+ const s = status;
21
+ if (typeof s.status !== "string") {
22
+ return null;
23
+ }
24
+ const mcpStatus = { status: s.status };
25
+ if ("error" in s && typeof s.error === "string") {
26
+ mcpStatus.error = s.error;
27
+ }
28
+ servers.push({ name, status: mcpStatus });
29
+ }
30
+ return servers;
31
+ }
32
+ const entries = Object.entries(value);
33
+ const servers = [];
34
+ for (const [name, status] of entries) {
35
+ if (!status || typeof status !== "object") {
36
+ return null;
37
+ }
38
+ const s = status;
39
+ if (typeof s.status !== "string") {
40
+ return null;
41
+ }
42
+ const mcpStatus = { status: s.status };
43
+ if ("error" in s && typeof s.error === "string") {
44
+ mcpStatus.error = s.error;
45
+ }
46
+ servers.push({ name, status: mcpStatus });
47
+ }
48
+ return servers;
49
+ }
50
+ export async function loadMcpCatalog(projectDirectory) {
51
+ const { data, error } = await opencodeClient.mcp.status({
52
+ directory: normalizeDirectoryForMcpApi(projectDirectory),
53
+ });
54
+ if (error || !data) {
55
+ throw error || new Error("No MCP status data received");
56
+ }
57
+ const servers = parseMcpCatalogServers(data);
58
+ if (!servers) {
59
+ throw new Error("Invalid MCP status data format");
60
+ }
61
+ return servers;
62
+ }
63
+ export async function toggleMcpCatalogServer(projectDirectory, serverName, enable) {
64
+ const params = {
65
+ name: serverName,
66
+ directory: normalizeDirectoryForMcpApi(projectDirectory),
67
+ };
68
+ if (enable) {
69
+ const { error } = await opencodeClient.mcp.connect(params);
70
+ if (error) {
71
+ throw error;
72
+ }
73
+ return;
74
+ }
75
+ const { error } = await opencodeClient.mcp.disconnect(params);
76
+ if (error) {
77
+ throw error;
78
+ }
79
+ }
@@ -0,0 +1,81 @@
1
+ import { opencodeClient } from "../../opencode/client.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ const LATEST_ASSISTANT_RESPONSE_MESSAGES_LIMIT = 20;
4
+ function extractTextParts(parts) {
5
+ const text = parts
6
+ .filter((part) => part.type === "text" && typeof part.text === "string")
7
+ .map((part) => part.text)
8
+ .join("")
9
+ .trim();
10
+ return text.length > 0 ? text : null;
11
+ }
12
+ export async function loadUserMessages(sessionId, directory) {
13
+ const { data, error } = await opencodeClient.session.messages({
14
+ sessionID: sessionId,
15
+ directory,
16
+ });
17
+ if (error || !data) {
18
+ throw error || new Error("No message data received");
19
+ }
20
+ const { data: sessionData } = await opencodeClient.session.get({
21
+ sessionID: sessionId,
22
+ directory,
23
+ });
24
+ const revertMessageID = sessionData?.revert?.messageID;
25
+ const messages = data
26
+ .map((message) => {
27
+ if (message.info.role !== "user") {
28
+ return null;
29
+ }
30
+ const text = extractTextParts(message.parts);
31
+ if (!text) {
32
+ return null;
33
+ }
34
+ return {
35
+ id: message.info.id ?? `${message.info.time?.created ?? 0}`,
36
+ text,
37
+ created: message.info.time?.created ?? 0,
38
+ };
39
+ })
40
+ .filter((message) => Boolean(message))
41
+ .sort((a, b) => b.created - a.created);
42
+ if (revertMessageID) {
43
+ const revertIndex = messages.findIndex((msg) => msg.id === revertMessageID);
44
+ if (revertIndex !== -1) {
45
+ return messages.slice(revertIndex + 1);
46
+ }
47
+ }
48
+ return messages;
49
+ }
50
+ export async function loadLatestAssistantResponse(sessionId, directory) {
51
+ try {
52
+ const { data: messages, error } = await opencodeClient.session.messages({
53
+ sessionID: sessionId,
54
+ directory,
55
+ limit: LATEST_ASSISTANT_RESPONSE_MESSAGES_LIMIT,
56
+ });
57
+ if (error || !messages) {
58
+ logger.warn("[Messages] Failed to fetch latest assistant response:", error);
59
+ return null;
60
+ }
61
+ const latestResponse = messages.reduce((latest, message) => {
62
+ if (message.info.role !== "assistant" || message.info.summary) {
63
+ return latest;
64
+ }
65
+ const text = extractTextParts(message.parts);
66
+ if (!text) {
67
+ return latest;
68
+ }
69
+ const created = message.info.time?.created ?? 0;
70
+ if (!latest || created >= latest.created) {
71
+ return { text, created };
72
+ }
73
+ return latest;
74
+ }, null);
75
+ return latestResponse?.text ?? null;
76
+ }
77
+ catch (err) {
78
+ logger.error("[Messages] Error loading latest assistant response:", err);
79
+ return null;
80
+ }
81
+ }
@@ -1,5 +1,5 @@
1
- import { opencodeClient } from "../opencode/client.js";
2
- import { logger } from "../utils/logger.js";
1
+ import { opencodeClient } from "../../opencode/client.js";
2
+ import { logger } from "../../utils/logger.js";
3
3
  const capabilitiesCache = {};
4
4
  /**
5
5
  * Get model capabilities from OpenCode API
@@ -1,7 +1,7 @@
1
- import { opencodeClient } from "../opencode/client.js";
2
- import { logger } from "../utils/logger.js";
3
- import { DEFAULT_CONTEXT_LIMIT } from "../pinned/format.js";
4
- import { isExpectedOpencodeUnavailableError } from "../utils/opencode-error.js";
1
+ import { opencodeClient } from "../../opencode/client.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ import { isExpectedOpencodeUnavailableError } from "../../utils/opencode-error.js";
4
+ export const DEFAULT_CONTEXT_LIMIT = 200000;
5
5
  const PROVIDER_CACHE_TTL_MS = 10 * 60 * 1000;
6
6
  const contextLimitCache = new Map();
7
7
  let providersCacheExpiresAt = 0;
@@ -1,7 +1,7 @@
1
- import { getCurrentModel, setCurrentModel } from "../settings/manager.js";
2
- import { config } from "../config.js";
3
- import { opencodeClient } from "../opencode/client.js";
4
- import { logger } from "../utils/logger.js";
1
+ import { getCurrentModel, setCurrentModel } from "../stores/settings-store.js";
2
+ import { config } from "../../config.js";
3
+ import { opencodeClient } from "../../opencode/client.js";
4
+ import { logger } from "../../utils/logger.js";
5
5
  import path from "node:path";
6
6
  const MODEL_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000;
7
7
  const SERVER_UNAVAILABLE_ERROR_MARKERS = [
@@ -1,8 +1,8 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { opencodeClient } from "../opencode/client.js";
4
- import { getCachedSessionProjects } from "../session/cache-manager.js";
5
- import { logger } from "../utils/logger.js";
3
+ import { opencodeClient } from "../../opencode/client.js";
4
+ import { getCachedSessionProjects } from "./session-cache-service.js";
5
+ import { logger } from "../../utils/logger.js";
6
6
  async function getResolvedProjects(options) {
7
7
  const includeLinkedWorktrees = options?.includeLinkedWorktrees === true;
8
8
  const { data: projects, error } = await opencodeClient.project.list();
@@ -0,0 +1,39 @@
1
+ import { setCurrentProject } from "../stores/settings-store.js";
2
+ import { clearSession } from "./session-service.js";
3
+ import { summaryAggregator } from "../managers/summary-aggregation-manager.js";
4
+ import { detachAttachedSession } from "./attach-service.js";
5
+ import { stopEventListening } from "../../opencode/events.js";
6
+ import { backgroundSessionTracker } from "../managers/background-session-manager.js";
7
+ import { getStoredAgent, resolveProjectAgent } from "./agent-selection-service.js";
8
+ import { getStoredModel } from "./model-selection-service.js";
9
+ import { formatVariantForButton } from "./variant-selection-service.js";
10
+ import { clearAllInteractionState } from "../managers/interaction-manager.js";
11
+ import { logger } from "../../utils/logger.js";
12
+ import { config } from "../../config.js";
13
+ export async function switchToProject(ctx, project, reason, options) {
14
+ detachAttachedSession(reason);
15
+ stopEventListening();
16
+ backgroundSessionTracker.clear();
17
+ setCurrentProject(project);
18
+ clearSession();
19
+ summaryAggregator.clear();
20
+ clearAllInteractionState(reason);
21
+ try {
22
+ await options.presentation.clearPinnedMessage();
23
+ }
24
+ catch (err) {
25
+ logger.error("[Bot] Error clearing pinned message:", err);
26
+ }
27
+ options.presentation.initializeKeyboard(ctx);
28
+ const contextLimit = await options.presentation.refreshContextLimit();
29
+ options.presentation.updateKeyboardContext({ tokensUsed: 0, tokensLimit: contextLimit });
30
+ const currentAgent = await resolveProjectAgent(getStoredAgent());
31
+ const currentModel = getStoredModel();
32
+ const contextInfo = { tokensUsed: 0, tokensLimit: contextLimit };
33
+ const variantName = formatVariantForButton(currentModel.variant || "default");
34
+ options.presentation.updateKeyboardAgent(currentAgent);
35
+ if (config.bot.trackBackgroundSessions && options.ensureEventSubscription) {
36
+ await options.ensureEventSubscription(project.worktree);
37
+ }
38
+ return options.presentation.createMainKeyboard(currentAgent, currentModel, contextInfo, variantName);
39
+ }
@@ -1,8 +1,7 @@
1
- import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
2
- import { attachManager } from "../../attach/manager.js";
3
- import { reconcileBusyStateNow } from "./busy-reconciliation.js";
4
- import { t } from "../../i18n/index.js";
1
+ import { attachManager } from "../managers/attach-manager.js";
2
+ import { foregroundSessionState } from "../managers/foreground-session-state-manager.js";
5
3
  import { logger } from "../../utils/logger.js";
4
+ import { reconcileBusyStateNow } from "./busy-reconciliation-service.js";
6
5
  export function isForegroundBusy() {
7
6
  return foregroundSessionState.isBusy() || attachManager.isBusy();
8
7
  }
@@ -30,13 +29,3 @@ export async function reconcileForegroundBusyState() {
30
29
  }
31
30
  }
32
31
  }
33
- export async function replyBusyBlocked(ctx) {
34
- const message = t("bot.session_busy");
35
- if (ctx.callbackQuery) {
36
- await ctx.answerCallbackQuery({ text: message }).catch(() => { });
37
- return;
38
- }
39
- if (ctx.chat) {
40
- await ctx.reply(message).catch(() => { });
41
- }
42
- }
@@ -1,8 +1,8 @@
1
- import { config } from "../config.js";
2
- import { t } from "../i18n/index.js";
3
- import { opencodeClient } from "../opencode/client.js";
4
- import { logger } from "../utils/logger.js";
5
- import { cleanupScheduledTaskSessionIgnores, registerScheduledTaskSessionIgnore, } from "./session-ignore.js";
1
+ import { config } from "../../config.js";
2
+ import { t } from "../../i18n/index.js";
3
+ import { opencodeClient } from "../../opencode/client.js";
4
+ import { logger } from "../../utils/logger.js";
5
+ import { cleanupScheduledTaskSessionIgnores, registerScheduledTaskSessionIgnore, } from "./scheduled-task-session-ignore-service.js";
6
6
  export const SCHEDULED_TASK_AGENT = "build";
7
7
  const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
8
8
  const EXECUTION_POLL_INTERVAL_MS = 2000;