@grinev/opencode-telegram-bot 0.21.0 → 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 (182) hide show
  1. package/.env.example +18 -2
  2. package/README.md +21 -8
  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 -1130
  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/bot/{utils → messages}/telegram-text.js +60 -0
  119. package/dist/{interaction/guard.js → bot/middleware/interaction-guard-decision.js} +11 -4
  120. package/dist/bot/middleware/interaction-guard.js +2 -2
  121. package/dist/bot/middleware/unknown-command.js +1 -1
  122. package/dist/{pinned/format.js → bot/pinned/pinned-message-format.js} +1 -2
  123. package/dist/{pinned/manager.js → bot/pinned/pinned-message-manager.js} +10 -10
  124. package/dist/bot/pinned/pinned-message-types.js +1 -0
  125. package/dist/{summary → bot/render}/markdown-to-telegram-v2.js +2 -2
  126. package/dist/bot/render/types.js +1 -0
  127. package/dist/bot/routers/command-router.js +73 -0
  128. package/dist/bot/routers/message-router.js +150 -0
  129. package/dist/bot/services/attach-presentation.js +40 -0
  130. package/dist/bot/services/event-subscription-service.js +842 -0
  131. package/dist/bot/services/project-switch-presentation.js +26 -0
  132. package/dist/bot/streaming/response-streamer.js +18 -2
  133. package/dist/cli.js +4 -4
  134. package/dist/config.js +21 -3
  135. package/dist/i18n/ar.js +491 -0
  136. package/dist/i18n/index.js +7 -0
  137. package/dist/index.js +1 -1
  138. package/dist/opencode/ready-refresh.js +2 -2
  139. package/dist/{service → runtime/service}/manager.js +2 -2
  140. package/dist/runtime/service/types.js +1 -0
  141. package/package.json +1 -1
  142. package/dist/bot/commands/ls.js +0 -445
  143. package/dist/bot/commands/open.js +0 -319
  144. package/dist/bot/commands/worktree.js +0 -201
  145. package/dist/bot/handlers/permission.js +0 -235
  146. package/dist/bot/utils/browser-roots.js +0 -140
  147. package/dist/bot/utils/file-tree.js +0 -92
  148. package/dist/bot/utils/send-tts-response.js +0 -37
  149. package/dist/bot/utils/switch-project.js +0 -45
  150. package/dist/bot/utils/telegram-file-url.js +0 -11
  151. package/dist/interaction/busy.js +0 -8
  152. package/dist/interaction/cleanup.js +0 -32
  153. /package/dist/{scheduled-task/display.js → app/formatters/scheduled-task-display-formatter.js} +0 -0
  154. /package/dist/{bot/utils/abort-error-suppression.js → app/managers/abort-suppression-manager.js} +0 -0
  155. /package/dist/{external-input/suppression.js → app/managers/external-input-suppression-manager.js} +0 -0
  156. /package/dist/{scheduled-task/next-run.js → app/services/scheduled-task-next-run-service.js} +0 -0
  157. /package/dist/{git/worktree.js → app/services/worktree-service.js} +0 -0
  158. /package/dist/{agent/types.js → app/types/agent.js} +0 -0
  159. /package/dist/{interaction/types.js → app/types/interaction.js} +0 -0
  160. /package/dist/{model/types.js → app/types/model.js} +0 -0
  161. /package/dist/{keyboard/types.js → app/types/permission.js} +0 -0
  162. /package/dist/{permission/types.js → app/types/project.js} +0 -0
  163. /package/dist/{pinned/types.js → app/types/question.js} +0 -0
  164. /package/dist/{scheduled-task/types.js → app/types/scheduled-task.js} +0 -0
  165. /package/dist/{question/types.js → app/types/session.js} +0 -0
  166. /package/dist/{service/types.js → app/types/settings.js} +0 -0
  167. /package/dist/{telegram/render/types.js → app/types/summary.js} +0 -0
  168. /package/dist/{variant/types.js → app/types/variant.js} +0 -0
  169. /package/dist/bot/commands/{help.js → help-command.js} +0 -0
  170. /package/dist/bot/{utils → messages}/send-with-markdown-fallback.js +0 -0
  171. /package/dist/bot/{utils → messages}/thinking-message.js +0 -0
  172. /package/dist/{telegram → bot}/render/block-fallback.js +0 -0
  173. /package/dist/{telegram → bot}/render/block-parser.js +0 -0
  174. /package/dist/{telegram → bot}/render/block-renderer.js +0 -0
  175. /package/dist/{telegram → bot}/render/chunker.js +0 -0
  176. /package/dist/{telegram → bot}/render/inline-renderer.js +0 -0
  177. /package/dist/{telegram → bot}/render/markdown-normalizer.js +0 -0
  178. /package/dist/{telegram → bot}/render/pipeline.js +0 -0
  179. /package/dist/{telegram → bot}/render/validator.js +0 -0
  180. /package/dist/bot/{utils/commands.js → routers/command-utils.js} +0 -0
  181. /package/dist/bot/{utils → streaming}/finalize-assistant-response.js +0 -0
  182. /package/dist/{service/runtime.js → runtime/service/env.js} +0 -0
@@ -1,18 +1,8 @@
1
- import { InlineKeyboard } from "grammy";
2
- import { opencodeClient } from "../../opencode/client.js";
3
- import { getCurrentProject } from "../../settings/manager.js";
4
- import { interactionManager } from "../../interaction/manager.js";
1
+ import { loadMcpCatalog, toggleMcpCatalogServer, } from "../../app/services/mcp-catalog-service.js";
2
+ import { interactionManager } from "../../app/managers/interaction-manager.js";
5
3
  import { logger } from "../../utils/logger.js";
6
4
  import { t } from "../../i18n/index.js";
7
- const MCPS_CALLBACK_PREFIX = "mcps:";
8
- const MCPS_CALLBACK_SELECT_PREFIX = `${MCPS_CALLBACK_PREFIX}select:`;
9
- const MCPS_CALLBACK_TOGGLE = `${MCPS_CALLBACK_PREFIX}toggle`;
10
- const MCPS_CALLBACK_BACK = `${MCPS_CALLBACK_PREFIX}back`;
11
- const MCPS_CALLBACK_CANCEL = `${MCPS_CALLBACK_PREFIX}cancel`;
12
- const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
13
- function normalizeDirectoryForApi(directory) {
14
- return directory.replace(/\\/g, "/");
15
- }
5
+ import { buildMcpsDetailKeyboard, buildMcpsDetailText, buildMcpsListKeyboard, MCPS_CALLBACK_BACK, MCPS_CALLBACK_CANCEL, MCPS_CALLBACK_PREFIX, MCPS_CALLBACK_SELECT_PREFIX, MCPS_CALLBACK_TOGGLE, parseMcpSelectCallback, } from "../menus/mcp-catalog-menu.js";
16
6
  function getCallbackMessageId(ctx) {
17
7
  const message = ctx.callbackQuery?.message;
18
8
  if (!message || !("message_id" in message)) {
@@ -21,87 +11,6 @@ function getCallbackMessageId(ctx) {
21
11
  const messageId = message.message_id;
22
12
  return typeof messageId === "number" ? messageId : null;
23
13
  }
24
- function getStatusLabel(status) {
25
- switch (status.status) {
26
- case "connected":
27
- return t("mcps.status.connected");
28
- case "disabled":
29
- return t("mcps.status.disabled");
30
- case "failed":
31
- return t("mcps.status.failed");
32
- case "needs_auth":
33
- return t("mcps.status.needs_auth");
34
- case "needs_client_registration":
35
- return t("mcps.status.needs_client_registration");
36
- default:
37
- return t("common.unknown");
38
- }
39
- }
40
- function getStatusEmoji(status) {
41
- switch (status.status) {
42
- case "connected":
43
- return "🟢";
44
- case "disabled":
45
- return "🔴";
46
- case "failed":
47
- return "⚠️";
48
- case "needs_auth":
49
- return "🔒";
50
- case "needs_client_registration":
51
- return "🔒";
52
- default:
53
- return "❓";
54
- }
55
- }
56
- function formatMcpButtonLabel(server) {
57
- const rawLabel = `${getStatusEmoji(server.status)} ${server.name}`;
58
- if (rawLabel.length <= MAX_INLINE_BUTTON_LABEL_LENGTH) {
59
- return rawLabel;
60
- }
61
- return `${rawLabel.slice(0, MAX_INLINE_BUTTON_LABEL_LENGTH - 3)}...`;
62
- }
63
- function buildMcpsListKeyboard(servers) {
64
- const keyboard = new InlineKeyboard();
65
- servers.forEach((server, index) => {
66
- keyboard
67
- .text(formatMcpButtonLabel(server), `${MCPS_CALLBACK_SELECT_PREFIX}${index}`)
68
- .row();
69
- });
70
- keyboard.text(t("inline.button.cancel"), MCPS_CALLBACK_CANCEL);
71
- return keyboard;
72
- }
73
- function buildMcpsDetailKeyboard(server) {
74
- const keyboard = new InlineKeyboard();
75
- let hasToggleButton = false;
76
- if (server.status.status === "connected") {
77
- keyboard.text(t("mcps.button.disable"), MCPS_CALLBACK_TOGGLE);
78
- hasToggleButton = true;
79
- }
80
- else if (server.status.status === "disabled" || server.status.status === "failed") {
81
- keyboard.text(t("mcps.button.enable"), MCPS_CALLBACK_TOGGLE);
82
- hasToggleButton = true;
83
- }
84
- if (hasToggleButton) {
85
- keyboard.row();
86
- }
87
- keyboard.text(t("mcps.button.back"), MCPS_CALLBACK_BACK);
88
- keyboard.text(t("inline.button.cancel"), MCPS_CALLBACK_CANCEL);
89
- return keyboard;
90
- }
91
- function buildMcpsDetailText(server) {
92
- const lines = [];
93
- lines.push(t("mcps.detail.title", { name: server.name }));
94
- lines.push("");
95
- lines.push(t("mcps.detail.status", { status: getStatusLabel(server.status) }));
96
- if (server.status.status === "failed" || server.status.status === "needs_client_registration") {
97
- lines.push(t("mcps.detail.error", { error: server.status.error }));
98
- }
99
- if (server.status.status === "needs_auth" || server.status.status === "needs_client_registration") {
100
- lines.push("");
101
- lines.push(t("mcps.auth_required"));
102
- }
103
- return lines.join("\n");
104
- }
105
14
  function parseMcpsServers(value) {
106
15
  if (!value || typeof value !== "object") {
107
16
  return null;
@@ -193,80 +102,6 @@ function clearMcpsInteraction(reason) {
193
102
  interactionManager.clear(reason);
194
103
  }
195
104
  }
196
- function parseSelectIndex(data) {
197
- if (!data.startsWith(MCPS_CALLBACK_SELECT_PREFIX)) {
198
- return null;
199
- }
200
- const index = Number(data.slice(MCPS_CALLBACK_SELECT_PREFIX.length));
201
- if (!Number.isInteger(index) || index < 0) {
202
- return null;
203
- }
204
- return index;
205
- }
206
- async function getMcpServerList(projectDirectory) {
207
- const { data, error } = await opencodeClient.mcp.status({
208
- directory: normalizeDirectoryForApi(projectDirectory),
209
- });
210
- if (error || !data) {
211
- throw error || new Error("No MCP status data received");
212
- }
213
- const servers = parseMcpsServers(data);
214
- if (!servers) {
215
- throw new Error("Invalid MCP status data format");
216
- }
217
- return servers;
218
- }
219
- async function toggleMcpServer(projectDirectory, serverName, enable) {
220
- const params = {
221
- name: serverName,
222
- directory: normalizeDirectoryForApi(projectDirectory),
223
- };
224
- if (enable) {
225
- const { error } = await opencodeClient.mcp.connect(params);
226
- if (error) {
227
- throw error;
228
- }
229
- }
230
- else {
231
- const { error } = await opencodeClient.mcp.disconnect(params);
232
- if (error) {
233
- throw error;
234
- }
235
- }
236
- }
237
- export async function mcpsCommand(ctx) {
238
- try {
239
- const currentProject = getCurrentProject();
240
- if (!currentProject) {
241
- await ctx.reply(t("bot.project_not_selected"));
242
- return;
243
- }
244
- const servers = await getMcpServerList(currentProject.worktree);
245
- if (servers.length === 0) {
246
- await ctx.reply(t("mcps.empty"));
247
- return;
248
- }
249
- const keyboard = buildMcpsListKeyboard(servers);
250
- const message = await ctx.reply(t("mcps.select"), {
251
- reply_markup: keyboard,
252
- });
253
- interactionManager.start({
254
- kind: "custom",
255
- expectedInput: "callback",
256
- metadata: {
257
- flow: "mcps",
258
- stage: "list",
259
- messageId: message.message_id,
260
- projectDirectory: currentProject.worktree,
261
- servers,
262
- },
263
- });
264
- }
265
- catch (error) {
266
- logger.error("[Mcps] Error fetching MCP servers list:", error);
267
- await ctx.reply(t("mcps.fetch_error"));
268
- }
269
- }
270
105
  export async function handleMcpsCallback(ctx) {
271
106
  const data = ctx.callbackQuery?.data;
272
107
  if (!data || !data.startsWith(MCPS_CALLBACK_PREFIX)) {
@@ -290,7 +125,7 @@ export async function handleMcpsCallback(ctx) {
290
125
  await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
291
126
  return true;
292
127
  }
293
- const servers = await getMcpServerList(metadata.projectDirectory);
128
+ const servers = await loadMcpCatalog(metadata.projectDirectory);
294
129
  const keyboard = buildMcpsListKeyboard(servers);
295
130
  await ctx.editMessageText(t("mcps.select"), { reply_markup: keyboard });
296
131
  await ctx.answerCallbackQuery();
@@ -319,8 +154,8 @@ export async function handleMcpsCallback(ctx) {
319
154
  }
320
155
  const enable = server.status.status !== "connected";
321
156
  await ctx.answerCallbackQuery({ text: enable ? t("mcps.enabling") : t("mcps.disabling") });
322
- await toggleMcpServer(metadata.projectDirectory, serverName, enable);
323
- const updatedServers = await getMcpServerList(metadata.projectDirectory);
157
+ await toggleMcpCatalogServer(metadata.projectDirectory, serverName, enable);
158
+ const updatedServers = await loadMcpCatalog(metadata.projectDirectory);
324
159
  const updatedServer = updatedServers.find((s) => s.name === serverName);
325
160
  if (!updatedServer) {
326
161
  await ctx.editMessageText(t("mcps.select"), {
@@ -359,7 +194,7 @@ export async function handleMcpsCallback(ctx) {
359
194
  await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
360
195
  return true;
361
196
  }
362
- const serverIndex = parseSelectIndex(data);
197
+ const serverIndex = parseMcpSelectCallback(data);
363
198
  const server = serverIndex === null ? undefined : metadata.servers[serverIndex];
364
199
  if (!server) {
365
200
  await ctx.answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true });
@@ -1,27 +1,18 @@
1
- import { InlineKeyboard } from "grammy";
2
1
  import { config } from "../../config.js";
3
- import { interactionManager } from "../../interaction/manager.js";
2
+ import { clearAllInteractionState, interactionManager } from "../../app/managers/interaction-manager.js";
4
3
  import { opencodeClient } from "../../opencode/client.js";
5
- import { getCurrentSession, setCurrentSession } from "../../session/manager.js";
6
- import { getCurrentProject } from "../../settings/manager.js";
7
- import { clearAllInteractionState } from "../../interaction/cleanup.js";
8
- import { attachToSession } from "../../attach/service.js";
9
- import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
4
+ import { setCurrentSession } from "../../app/services/session-service.js";
5
+ import { attachToSession } from "../../app/services/attach-service.js";
6
+ import { ingestSessionInfoForCache } from "../../app/services/session-cache-service.js";
7
+ import { loadLatestAssistantResponse } from "../../app/services/message-history-service.js";
8
+ import { isForegroundBusy } from "../../app/services/run-control-service.js";
10
9
  import { t } from "../../i18n/index.js";
11
10
  import { logger } from "../../utils/logger.js";
12
11
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
13
- import { renderAssistantFinalPartsSafe } from "../utils/assistant-rendering.js";
14
- import { sendRenderedBotPart } from "../utils/telegram-text.js";
15
- const MESSAGES_CALLBACK_PREFIX = "messages:";
16
- const MESSAGES_CALLBACK_SELECT_PREFIX = `${MESSAGES_CALLBACK_PREFIX}select:`;
17
- const MESSAGES_CALLBACK_PAGE_PREFIX = `${MESSAGES_CALLBACK_PREFIX}page:`;
18
- const MESSAGES_CALLBACK_REVERT = `${MESSAGES_CALLBACK_PREFIX}revert`;
19
- const MESSAGES_CALLBACK_FORK = `${MESSAGES_CALLBACK_PREFIX}fork`;
20
- const MESSAGES_CALLBACK_BACK = `${MESSAGES_CALLBACK_PREFIX}back`;
21
- const MESSAGES_CALLBACK_CANCEL = `${MESSAGES_CALLBACK_PREFIX}cancel`;
22
- const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
23
- const TELEGRAM_MESSAGE_LIMIT = 4096;
24
- const LATEST_ASSISTANT_RESPONSE_MESSAGES_LIMIT = 20;
12
+ import { renderAssistantFinalPartsSafe } from "../messages/assistant-rendering.js";
13
+ import { replyBusyBlocked } from "../messages/busy-blocked-renderer.js";
14
+ import { sendRenderedBotPart } from "../messages/telegram-text.js";
15
+ import { buildMessageDetailKeyboard, buildMessagesListKeyboard, calculateMessagesPaginationRange, formatMessageDetailText, formatMessagesSelectText, MESSAGES_CALLBACK_BACK, MESSAGES_CALLBACK_CANCEL, MESSAGES_CALLBACK_FORK, MESSAGES_CALLBACK_PREFIX, MESSAGES_CALLBACK_REVERT, parseMessagePageCallback, parseMessageSelectCallback, TELEGRAM_MESSAGE_LIMIT, truncateMessageHistoryText, } from "../menus/message-history-menu.js";
25
16
  function getCallbackMessageId(ctx) {
26
17
  const message = ctx.callbackQuery?.message;
27
18
  if (!message || !("message_id" in message)) {
@@ -30,99 +21,6 @@ function getCallbackMessageId(ctx) {
30
21
  const messageId = message.message_id;
31
22
  return typeof messageId === "number" ? messageId : null;
32
23
  }
33
- function extractTextParts(parts) {
34
- const text = parts
35
- .filter((part) => part.type === "text" && typeof part.text === "string")
36
- .map((part) => part.text)
37
- .join("")
38
- .trim();
39
- return text.length > 0 ? text : null;
40
- }
41
- function truncateText(text, maxLength) {
42
- if (text.length <= maxLength) {
43
- return text;
44
- }
45
- return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
46
- }
47
- function normalizeButtonText(text) {
48
- return text.replace(/\s+/g, " ").trim();
49
- }
50
- function formatMessageTime(created) {
51
- const date = new Date(created);
52
- const hours = date.getHours().toString().padStart(2, "0");
53
- const minutes = date.getMinutes().toString().padStart(2, "0");
54
- return `${hours}:${minutes}`;
55
- }
56
- function formatMessageButtonLabel(message) {
57
- const prefix = `[${formatMessageTime(message.created)}] `;
58
- const text = normalizeButtonText(message.text);
59
- return `${prefix}${truncateText(text, MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length)}`;
60
- }
61
- function formatMessagesSelectText(page) {
62
- if (page === 0) {
63
- return t("messages.select");
64
- }
65
- return t("messages.select_page", { page: page + 1 });
66
- }
67
- function formatMessageDetailText(message) {
68
- const prefix = `[${formatMessageTime(message.created)}]\n\n`;
69
- return truncateText(`${prefix}${message.text}`, TELEGRAM_MESSAGE_LIMIT);
70
- }
71
- export function buildMessagePageCallback(page) {
72
- return `${MESSAGES_CALLBACK_PAGE_PREFIX}${page}`;
73
- }
74
- export function parseMessagePageCallback(data) {
75
- if (!data.startsWith(MESSAGES_CALLBACK_PAGE_PREFIX)) {
76
- return null;
77
- }
78
- const rawPage = data.slice(MESSAGES_CALLBACK_PAGE_PREFIX.length);
79
- const page = Number(rawPage);
80
- if (!Number.isInteger(page) || page < 0) {
81
- return null;
82
- }
83
- return page;
84
- }
85
- export function calculateMessagesPaginationRange(totalMessages, page, pageSize) {
86
- const safePageSize = Math.max(1, pageSize);
87
- const totalPages = Math.max(1, Math.ceil(totalMessages / safePageSize));
88
- const normalizedPage = Math.min(Math.max(0, page), totalPages - 1);
89
- const startIndex = normalizedPage * safePageSize;
90
- const endIndex = Math.min(startIndex + safePageSize, totalMessages);
91
- return {
92
- page: normalizedPage,
93
- totalPages,
94
- startIndex,
95
- endIndex,
96
- };
97
- }
98
- function buildMessagesListKeyboard(messages, page, pageSize) {
99
- const keyboard = new InlineKeyboard();
100
- const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateMessagesPaginationRange(messages.length, page, pageSize);
101
- messages.slice(startIndex, endIndex).forEach((message, index) => {
102
- const globalIndex = startIndex + index;
103
- keyboard.text(formatMessageButtonLabel(message), `${MESSAGES_CALLBACK_SELECT_PREFIX}${globalIndex}`).row();
104
- });
105
- if (totalPages > 1) {
106
- if (normalizedPage > 0) {
107
- keyboard.text(t("messages.button.prev_page"), buildMessagePageCallback(normalizedPage - 1));
108
- }
109
- if (normalizedPage < totalPages - 1) {
110
- keyboard.text(t("messages.button.next_page"), buildMessagePageCallback(normalizedPage + 1));
111
- }
112
- keyboard.row();
113
- }
114
- keyboard.text(t("messages.button.cancel"), MESSAGES_CALLBACK_CANCEL);
115
- return keyboard;
116
- }
117
- function buildMessageDetailKeyboard() {
118
- return new InlineKeyboard()
119
- .text(t("messages.button.revert"), MESSAGES_CALLBACK_REVERT)
120
- .row()
121
- .text(t("messages.button.fork"), MESSAGES_CALLBACK_FORK)
122
- .row()
123
- .text(t("messages.button.back"), MESSAGES_CALLBACK_BACK)
124
- .text(t("messages.button.cancel"), MESSAGES_CALLBACK_CANCEL);
125
- }
126
24
  function parseMessages(value) {
127
25
  if (!Array.isArray(value)) {
128
26
  return null;
@@ -197,83 +95,6 @@ function clearMessagesInteraction(reason) {
197
95
  interactionManager.clear(reason);
198
96
  }
199
97
  }
200
- async function loadUserMessages(sessionId, directory) {
201
- const { data, error } = await opencodeClient.session.messages({
202
- sessionID: sessionId,
203
- directory,
204
- });
205
- if (error || !data) {
206
- throw error || new Error("No message data received");
207
- }
208
- // Get session info to check for revert
209
- const { data: sessionData } = await opencodeClient.session.get({
210
- sessionID: sessionId,
211
- directory,
212
- });
213
- const revertMessageID = sessionData?.revert?.messageID;
214
- const messages = data
215
- .map((message) => {
216
- if (message.info.role !== "user") {
217
- return null;
218
- }
219
- const text = extractTextParts(message.parts);
220
- if (!text) {
221
- return null;
222
- }
223
- return {
224
- id: message.info.id ?? `${message.info.time?.created ?? 0}`,
225
- text,
226
- created: message.info.time?.created ?? 0,
227
- };
228
- })
229
- .filter((message) => Boolean(message))
230
- .sort((a, b) => b.created - a.created);
231
- // If there's a revert, filter messages to only include those before the revert point
232
- // Messages are sorted newest first, so we need to skip the revert message and everything after it
233
- if (revertMessageID) {
234
- const revertIndex = messages.findIndex((msg) => msg.id === revertMessageID);
235
- if (revertIndex !== -1) {
236
- return messages.slice(revertIndex + 1);
237
- }
238
- }
239
- return messages;
240
- }
241
- async function loadLatestAssistantResponse(sessionId, directory) {
242
- try {
243
- const { data: messages, error } = await opencodeClient.session.messages({
244
- sessionID: sessionId,
245
- directory,
246
- limit: LATEST_ASSISTANT_RESPONSE_MESSAGES_LIMIT,
247
- });
248
- if (error || !messages) {
249
- logger.warn("[Messages] Failed to fetch latest assistant response:", error);
250
- return null;
251
- }
252
- const latestResponse = messages.reduce((latest, message) => {
253
- if (message.info.role !== "assistant") {
254
- return latest;
255
- }
256
- const assistantInfo = message.info;
257
- if (assistantInfo.summary) {
258
- return latest;
259
- }
260
- const text = extractTextParts(message.parts);
261
- if (!text) {
262
- return latest;
263
- }
264
- const created = message.info.time?.created ?? 0;
265
- if (!latest || created >= latest.created) {
266
- return { text, created };
267
- }
268
- return latest;
269
- }, null);
270
- return latestResponse?.text ?? null;
271
- }
272
- catch (err) {
273
- logger.error("[Messages] Error loading latest assistant response:", err);
274
- return null;
275
- }
276
- }
277
98
  async function sendLatestAssistantResponse(api, chatId, sessionId, directory) {
278
99
  const responseText = await loadLatestAssistantResponse(sessionId, directory);
279
100
  if (!responseText) {
@@ -288,67 +109,15 @@ async function sendLatestAssistantResponse(api, chatId, sessionId, directory) {
288
109
  });
289
110
  }
290
111
  }
291
- function parseSelectIndex(data) {
292
- if (!data.startsWith(MESSAGES_CALLBACK_SELECT_PREFIX)) {
293
- return null;
294
- }
295
- const rawIndex = data.slice(MESSAGES_CALLBACK_SELECT_PREFIX.length);
296
- const index = Number(rawIndex);
297
- if (!Number.isInteger(index) || index < 0) {
298
- return null;
299
- }
300
- return index;
301
- }
302
- export async function messagesCommand(ctx) {
303
- try {
304
- const currentProject = getCurrentProject();
305
- if (!currentProject) {
306
- await ctx.reply(t("messages.project_not_selected"));
307
- return;
308
- }
309
- const currentSession = getCurrentSession();
310
- if (!currentSession) {
311
- await ctx.reply(t("messages.session_not_selected"));
312
- return;
313
- }
314
- if (currentSession.directory !== currentProject.worktree) {
315
- await ctx.reply(t("messages.session_project_mismatch"));
316
- return;
317
- }
318
- const messages = await loadUserMessages(currentSession.id, currentSession.directory);
319
- if (messages.length === 0) {
320
- await ctx.reply(t("messages.empty"));
321
- return;
322
- }
323
- const pageSize = config.bot.messagesListLimit;
324
- const keyboard = buildMessagesListKeyboard(messages, 0, pageSize);
325
- const message = await ctx.reply(formatMessagesSelectText(0), {
326
- reply_markup: keyboard,
327
- });
328
- interactionManager.start({
329
- kind: "custom",
330
- expectedInput: "callback",
331
- metadata: {
332
- flow: "messages",
333
- stage: "list",
334
- messageId: message.message_id,
335
- projectDirectory: currentProject.worktree,
336
- sessionId: currentSession.id,
337
- messages,
338
- page: 0,
339
- },
340
- });
341
- }
342
- catch (error) {
343
- logger.error("[Messages] Error fetching messages list:", error);
344
- await ctx.reply(t("messages.fetch_error"));
345
- }
346
- }
347
112
  export async function handleMessagesCallback(ctx, deps) {
348
113
  const data = ctx.callbackQuery?.data;
349
114
  if (!data || !data.startsWith(MESSAGES_CALLBACK_PREFIX)) {
350
115
  return false;
351
116
  }
117
+ if (isForegroundBusy()) {
118
+ await replyBusyBlocked(ctx);
119
+ return true;
120
+ }
352
121
  const metadata = parseMessagesMetadata(interactionManager.getSnapshot());
353
122
  const callbackMessageId = getCallbackMessageId(ctx);
354
123
  if (!metadata || callbackMessageId === null || metadata.messageId !== callbackMessageId) {
@@ -374,7 +143,7 @@ export async function handleMessagesCallback(ctx, deps) {
374
143
  messageID: selectedMessage.id,
375
144
  });
376
145
  const successText = t("messages.revert_success", { text: selectedMessage.text });
377
- await ctx.editMessageText(truncateText(successText, TELEGRAM_MESSAGE_LIMIT), {
146
+ await ctx.editMessageText(truncateMessageHistoryText(successText, TELEGRAM_MESSAGE_LIMIT), {
378
147
  reply_markup: undefined,
379
148
  });
380
149
  clearMessagesInteraction("messages_revert_success");
@@ -422,7 +191,7 @@ export async function handleMessagesCallback(ctx, deps) {
422
191
  ensureEventSubscription: deps.ensureEventSubscription,
423
192
  });
424
193
  const successText = t("messages.fork_success", { text: selectedMessage.text });
425
- await ctx.editMessageText(truncateText(successText, TELEGRAM_MESSAGE_LIMIT), {
194
+ await ctx.editMessageText(truncateMessageHistoryText(successText, TELEGRAM_MESSAGE_LIMIT), {
426
195
  reply_markup: undefined,
427
196
  });
428
197
  clearMessagesInteraction("messages_fork_success");
@@ -499,7 +268,7 @@ export async function handleMessagesCallback(ctx, deps) {
499
268
  });
500
269
  return true;
501
270
  }
502
- const messageIndex = parseSelectIndex(data);
271
+ const messageIndex = parseMessageSelectCallback(data);
503
272
  if (messageIndex === null || metadata.stage !== "list") {
504
273
  await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
505
274
  return true;
@@ -1,29 +1,16 @@
1
1
  import { InlineKeyboard } from "grammy";
2
- import { selectModel, fetchCurrentModel, getModelSelectionLists, searchModels, } from "../../model/manager.js";
3
- import { formatModelForDisplay } from "../../model/types.js";
4
- import { formatVariantForButton } from "../../variant/manager.js";
2
+ import { getStoredAgent, resolveProjectAgent } from "../../app/services/agent-selection-service.js";
3
+ import { searchModels, selectModel, } from "../../app/services/model-selection-service.js";
4
+ import { formatVariantForButton } from "../../app/services/variant-selection-service.js";
5
+ import { formatModelForDisplay } from "../../app/types/model.js";
6
+ import { interactionManager } from "../../app/managers/interaction-manager.js";
5
7
  import { logger } from "../../utils/logger.js";
6
- import { createMainKeyboard } from "../utils/keyboard.js";
7
- import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
8
- import { pinnedMessageManager } from "../../pinned/manager.js";
9
- import { keyboardManager } from "../../keyboard/manager.js";
10
- import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
11
- import { interactionManager } from "../../interaction/manager.js";
12
8
  import { t } from "../../i18n/index.js";
13
- const MODEL_SEARCH_CALLBACK = "model:search";
14
- const MODEL_SEARCH_AGAIN_CALLBACK = "model:search:again";
15
- const MODEL_SEARCH_CANCEL_CALLBACK = "model:search:cancel";
16
- function buildModelSelectionMenuText(modelLists) {
17
- const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
18
- if (modelLists.favorites.length === 0) {
19
- lines.push(t("model.menu.favorites_empty"));
20
- }
21
- lines.push(t("model.menu.recent_title"));
22
- if (modelLists.recent.length === 0) {
23
- lines.push(t("model.menu.recent_empty"));
24
- }
25
- return lines.join("\n");
26
- }
9
+ import { createMainKeyboard } from "../keyboards/main-reply-keyboard.js";
10
+ import { keyboardManager } from "../keyboards/keyboard-manager.js";
11
+ import { pinnedMessageManager } from "../pinned/pinned-message-manager.js";
12
+ import { clearActiveInlineMenu, ensureActiveInlineMenu } from "../menus/inline-menu.js";
13
+ import { MODEL_SEARCH_AGAIN_CALLBACK, MODEL_SEARCH_CALLBACK, MODEL_SEARCH_CANCEL_CALLBACK, } from "../menus/model-selection-menu.js";
27
14
  function parseModelSearchMetadata() {
28
15
  const state = interactionManager.getSnapshot();
29
16
  if (!state || state.kind !== "custom") {
@@ -114,54 +101,6 @@ export async function handleModelSelect(ctx) {
114
101
  return false;
115
102
  }
116
103
  }
117
- /**
118
- * Build inline keyboard with favorite and recent models, plus a search button at the top.
119
- */
120
- export async function buildModelSelectionMenu(currentModel, modelLists) {
121
- const keyboard = new InlineKeyboard();
122
- const lists = modelLists ?? (await getModelSelectionLists());
123
- const favorites = lists.favorites;
124
- const recent = lists.recent;
125
- // Search button — always present as first row
126
- keyboard.text(t("model.search.button"), MODEL_SEARCH_CALLBACK).row();
127
- if (favorites.length === 0 && recent.length === 0) {
128
- logger.warn("[ModelHandler] No model choices found in favorites/recent");
129
- return keyboard;
130
- }
131
- const addButton = (model, prefix) => {
132
- const isActive = currentModel &&
133
- model.providerID === currentModel.providerID &&
134
- model.modelID === currentModel.modelID;
135
- const label = `${prefix} ${model.providerID}/${model.modelID}`;
136
- const labelWithCheck = isActive ? `✅ ${label}` : label;
137
- keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
138
- };
139
- favorites.forEach((model) => addButton(model, "⭐"));
140
- recent.forEach((model) => addButton(model, "🕘"));
141
- return keyboard;
142
- }
143
- /**
144
- * Show model selection menu
145
- */
146
- export async function showModelSelectionMenu(ctx) {
147
- try {
148
- const currentModel = fetchCurrentModel();
149
- const modelLists = await getModelSelectionLists();
150
- const keyboard = await buildModelSelectionMenu(currentModel, modelLists);
151
- // keyboard always has at least the search button, so length > 0
152
- const text = buildModelSelectionMenuText(modelLists);
153
- await replyWithInlineMenu(ctx, {
154
- menuKind: "model",
155
- text,
156
- keyboard,
157
- });
158
- }
159
- catch (err) {
160
- logger.error("[ModelHandler] Error showing model menu:", err);
161
- await ctx.reply(t("model.menu.error"));
162
- }
163
- }
164
- // ─── Model search handlers ────────────────────────────────────────────────
165
104
  /**
166
105
  * Handle the search button callback (model:search) from the inline menu.
167
106
  * Transitions the interaction to text-input mode and prompts the user.