@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,107 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { t } from "../../i18n/index.js";
3
+ export const MESSAGES_CALLBACK_PREFIX = "messages:";
4
+ export const MESSAGES_CALLBACK_SELECT_PREFIX = `${MESSAGES_CALLBACK_PREFIX}select:`;
5
+ const MESSAGES_CALLBACK_PAGE_PREFIX = `${MESSAGES_CALLBACK_PREFIX}page:`;
6
+ export const MESSAGES_CALLBACK_REVERT = `${MESSAGES_CALLBACK_PREFIX}revert`;
7
+ export const MESSAGES_CALLBACK_FORK = `${MESSAGES_CALLBACK_PREFIX}fork`;
8
+ export const MESSAGES_CALLBACK_BACK = `${MESSAGES_CALLBACK_PREFIX}back`;
9
+ export const MESSAGES_CALLBACK_CANCEL = `${MESSAGES_CALLBACK_PREFIX}cancel`;
10
+ export const TELEGRAM_MESSAGE_LIMIT = 4096;
11
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
12
+ export function truncateMessageHistoryText(text, maxLength) {
13
+ if (text.length <= maxLength) {
14
+ return text;
15
+ }
16
+ return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
17
+ }
18
+ function normalizeButtonText(text) {
19
+ return text.replace(/\s+/g, " ").trim();
20
+ }
21
+ function formatMessageTime(created) {
22
+ const date = new Date(created);
23
+ const hours = date.getHours().toString().padStart(2, "0");
24
+ const minutes = date.getMinutes().toString().padStart(2, "0");
25
+ return `${hours}:${minutes}`;
26
+ }
27
+ function formatMessageButtonLabel(message) {
28
+ const prefix = `[${formatMessageTime(message.created)}] `;
29
+ const text = normalizeButtonText(message.text);
30
+ return `${prefix}${truncateMessageHistoryText(text, MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length)}`;
31
+ }
32
+ export function formatMessagesSelectText(page) {
33
+ if (page === 0) {
34
+ return t("messages.select");
35
+ }
36
+ return t("messages.select_page", { page: page + 1 });
37
+ }
38
+ export function formatMessageDetailText(message) {
39
+ const prefix = `[${formatMessageTime(message.created)}]\n\n`;
40
+ return truncateMessageHistoryText(`${prefix}${message.text}`, TELEGRAM_MESSAGE_LIMIT);
41
+ }
42
+ export function buildMessagePageCallback(page) {
43
+ return `${MESSAGES_CALLBACK_PAGE_PREFIX}${page}`;
44
+ }
45
+ export function parseMessagePageCallback(data) {
46
+ if (!data.startsWith(MESSAGES_CALLBACK_PAGE_PREFIX)) {
47
+ return null;
48
+ }
49
+ const rawPage = data.slice(MESSAGES_CALLBACK_PAGE_PREFIX.length);
50
+ const page = Number(rawPage);
51
+ if (!Number.isInteger(page) || page < 0) {
52
+ return null;
53
+ }
54
+ return page;
55
+ }
56
+ export function parseMessageSelectCallback(data) {
57
+ if (!data.startsWith(MESSAGES_CALLBACK_SELECT_PREFIX)) {
58
+ return null;
59
+ }
60
+ const rawIndex = data.slice(MESSAGES_CALLBACK_SELECT_PREFIX.length);
61
+ const index = Number(rawIndex);
62
+ if (!Number.isInteger(index) || index < 0) {
63
+ return null;
64
+ }
65
+ return index;
66
+ }
67
+ export function calculateMessagesPaginationRange(totalMessages, page, pageSize) {
68
+ const safePageSize = Math.max(1, pageSize);
69
+ const totalPages = Math.max(1, Math.ceil(totalMessages / safePageSize));
70
+ const normalizedPage = Math.min(Math.max(0, page), totalPages - 1);
71
+ const startIndex = normalizedPage * safePageSize;
72
+ const endIndex = Math.min(startIndex + safePageSize, totalMessages);
73
+ return {
74
+ page: normalizedPage,
75
+ totalPages,
76
+ startIndex,
77
+ endIndex,
78
+ };
79
+ }
80
+ export function buildMessagesListKeyboard(messages, page, pageSize) {
81
+ const keyboard = new InlineKeyboard();
82
+ const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateMessagesPaginationRange(messages.length, page, pageSize);
83
+ messages.slice(startIndex, endIndex).forEach((message, index) => {
84
+ const globalIndex = startIndex + index;
85
+ keyboard.text(formatMessageButtonLabel(message), `${MESSAGES_CALLBACK_SELECT_PREFIX}${globalIndex}`).row();
86
+ });
87
+ if (totalPages > 1) {
88
+ if (normalizedPage > 0) {
89
+ keyboard.text(t("messages.button.prev_page"), buildMessagePageCallback(normalizedPage - 1));
90
+ }
91
+ if (normalizedPage < totalPages - 1) {
92
+ keyboard.text(t("messages.button.next_page"), buildMessagePageCallback(normalizedPage + 1));
93
+ }
94
+ keyboard.row();
95
+ }
96
+ keyboard.text(t("messages.button.cancel"), MESSAGES_CALLBACK_CANCEL);
97
+ return keyboard;
98
+ }
99
+ export function buildMessageDetailKeyboard() {
100
+ return new InlineKeyboard()
101
+ .text(t("messages.button.revert"), MESSAGES_CALLBACK_REVERT)
102
+ .row()
103
+ .text(t("messages.button.fork"), MESSAGES_CALLBACK_FORK)
104
+ .row()
105
+ .text(t("messages.button.back"), MESSAGES_CALLBACK_BACK)
106
+ .text(t("messages.button.cancel"), MESSAGES_CALLBACK_CANCEL);
107
+ }
@@ -0,0 +1,66 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { fetchCurrentModel, getModelSelectionLists, } from "../../app/services/model-selection-service.js";
3
+ import { logger } from "../../utils/logger.js";
4
+ import { t } from "../../i18n/index.js";
5
+ import { replyWithInlineMenu } from "./inline-menu.js";
6
+ export const MODEL_SEARCH_CALLBACK = "model:search";
7
+ export const MODEL_SEARCH_AGAIN_CALLBACK = "model:search:again";
8
+ export const MODEL_SEARCH_CANCEL_CALLBACK = "model:search:cancel";
9
+ function buildModelSelectionMenuText(modelLists) {
10
+ const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
11
+ if (modelLists.favorites.length === 0) {
12
+ lines.push(t("model.menu.favorites_empty"));
13
+ }
14
+ lines.push(t("model.menu.recent_title"));
15
+ if (modelLists.recent.length === 0) {
16
+ lines.push(t("model.menu.recent_empty"));
17
+ }
18
+ return lines.join("\n");
19
+ }
20
+ /**
21
+ * Build inline keyboard with favorite and recent models, plus a search button at the top.
22
+ */
23
+ export async function buildModelSelectionMenu(currentModel, modelLists) {
24
+ const keyboard = new InlineKeyboard();
25
+ const lists = modelLists ?? (await getModelSelectionLists());
26
+ const favorites = lists.favorites;
27
+ const recent = lists.recent;
28
+ // Search button — always present as first row
29
+ keyboard.text(t("model.search.button"), MODEL_SEARCH_CALLBACK).row();
30
+ if (favorites.length === 0 && recent.length === 0) {
31
+ logger.warn("[ModelHandler] No model choices found in favorites/recent");
32
+ return keyboard;
33
+ }
34
+ const addButton = (model, prefix) => {
35
+ const isActive = currentModel &&
36
+ model.providerID === currentModel.providerID &&
37
+ model.modelID === currentModel.modelID;
38
+ const label = `${prefix} ${model.providerID}/${model.modelID}`;
39
+ const labelWithCheck = isActive ? `✅ ${label}` : label;
40
+ keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
41
+ };
42
+ favorites.forEach((model) => addButton(model, "⭐"));
43
+ recent.forEach((model) => addButton(model, "🕘"));
44
+ return keyboard;
45
+ }
46
+ /**
47
+ * Show model selection menu
48
+ */
49
+ export async function showModelSelectionMenu(ctx) {
50
+ try {
51
+ const currentModel = fetchCurrentModel();
52
+ const modelLists = await getModelSelectionLists();
53
+ const keyboard = await buildModelSelectionMenu(currentModel, modelLists);
54
+ // keyboard always has at least the search button, so length > 0
55
+ const text = buildModelSelectionMenuText(modelLists);
56
+ await replyWithInlineMenu(ctx, {
57
+ menuKind: "model",
58
+ text,
59
+ keyboard,
60
+ });
61
+ }
62
+ catch (err) {
63
+ logger.error("[ModelHandler] Error showing model menu:", err);
64
+ await ctx.reply(t("model.menu.error"));
65
+ }
66
+ }
@@ -0,0 +1,116 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { permissionManager } from "../../app/managers/permission-manager.js";
3
+ import { summaryAggregator } from "../../app/managers/summary-aggregation-manager.js";
4
+ import { interactionManager } from "../../app/managers/interaction-manager.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { t } from "../../i18n/index.js";
7
+ // Permission type display names
8
+ const PERMISSION_NAME_KEYS = {
9
+ bash: "permission.name.bash",
10
+ edit: "permission.name.edit",
11
+ write: "permission.name.write",
12
+ read: "permission.name.read",
13
+ webfetch: "permission.name.webfetch",
14
+ websearch: "permission.name.websearch",
15
+ glob: "permission.name.glob",
16
+ grep: "permission.name.grep",
17
+ list: "permission.name.list",
18
+ task: "permission.name.task",
19
+ lsp: "permission.name.lsp",
20
+ external_directory: "permission.name.external_directory",
21
+ };
22
+ // Permission type emojis
23
+ const PERMISSION_EMOJIS = {
24
+ bash: "⚡",
25
+ edit: "✏️",
26
+ write: "📝",
27
+ read: "📖",
28
+ webfetch: "🌐",
29
+ websearch: "🔍",
30
+ glob: "📁",
31
+ grep: "🔎",
32
+ list: "📂",
33
+ task: "⚙️",
34
+ lsp: "🔧",
35
+ external_directory: "📁",
36
+ };
37
+ export function clearPermissionInteraction(reason) {
38
+ const state = interactionManager.getSnapshot();
39
+ if (state?.kind === "permission") {
40
+ interactionManager.clear(reason);
41
+ }
42
+ }
43
+ export function syncPermissionInteractionState(metadata = {}) {
44
+ const pendingCount = permissionManager.getPendingCount();
45
+ if (pendingCount === 0) {
46
+ clearPermissionInteraction("permission_no_pending_requests");
47
+ return;
48
+ }
49
+ const nextMetadata = {
50
+ pendingCount,
51
+ ...metadata,
52
+ };
53
+ const state = interactionManager.getSnapshot();
54
+ if (state?.kind === "permission") {
55
+ interactionManager.transition({
56
+ expectedInput: "callback",
57
+ metadata: nextMetadata,
58
+ });
59
+ return;
60
+ }
61
+ interactionManager.start({
62
+ kind: "permission",
63
+ expectedInput: "callback",
64
+ metadata: nextMetadata,
65
+ });
66
+ }
67
+ /**
68
+ * Show permission request message with inline buttons
69
+ */
70
+ export async function showPermissionRequest(bot, chatId, request) {
71
+ logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);
72
+ const text = formatPermissionText(request);
73
+ const keyboard = buildPermissionKeyboard();
74
+ try {
75
+ const message = await bot.sendMessage(chatId, text, {
76
+ reply_markup: keyboard,
77
+ });
78
+ logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
79
+ permissionManager.startPermission(request, message.message_id);
80
+ syncPermissionInteractionState({
81
+ requestID: request.id,
82
+ messageId: message.message_id,
83
+ });
84
+ summaryAggregator.stopTypingIndicator();
85
+ }
86
+ catch (err) {
87
+ logger.error("[PermissionHandler] Failed to send permission message:", err);
88
+ throw err;
89
+ }
90
+ }
91
+ /**
92
+ * Format permission request text
93
+ */
94
+ function formatPermissionText(request) {
95
+ const emoji = PERMISSION_EMOJIS[request.permission] || "🔐";
96
+ const nameKey = PERMISSION_NAME_KEYS[request.permission];
97
+ const name = nameKey ? t(nameKey) : request.permission;
98
+ let text = t("permission.header", { emoji, name });
99
+ // Show patterns (commands/files)
100
+ if (request.patterns.length > 0) {
101
+ request.patterns.forEach((pattern) => {
102
+ text += `• ${pattern}\n`;
103
+ });
104
+ }
105
+ return text;
106
+ }
107
+ /**
108
+ * Build inline keyboard with permission buttons
109
+ */
110
+ function buildPermissionKeyboard() {
111
+ const keyboard = new InlineKeyboard();
112
+ keyboard.text(t("permission.button.allow"), "permission:once").row();
113
+ keyboard.text(t("permission.button.always"), "permission:always").row();
114
+ keyboard.text(t("permission.button.reject"), "permission:reject");
115
+ return keyboard;
116
+ }
@@ -1,17 +1,11 @@
1
1
  import { InlineKeyboard } from "grammy";
2
- import { getCurrentProject } from "../../settings/manager.js";
3
- import { getGitWorktreeContext } from "../../git/worktree.js";
4
- import { getProjects } from "../../project/manager.js";
5
- import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
6
- import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
7
- import { switchToProject } from "../utils/switch-project.js";
8
- import { clearAllInteractionState } from "../../interaction/cleanup.js";
9
- import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
10
- import { logger } from "../../utils/logger.js";
2
+ import { getGitWorktreeContext } from "../../app/services/worktree-service.js";
11
3
  import { t } from "../../i18n/index.js";
4
+ import { getCurrentProject } from "../../app/stores/settings-store.js";
5
+ import { logger } from "../../utils/logger.js";
12
6
  import { config } from "../../config.js";
13
7
  const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
14
- const PROJECT_PAGE_CALLBACK_PREFIX = "projects:page:";
8
+ export const PROJECT_PAGE_CALLBACK_PREFIX = "projects:page:";
15
9
  function formatProjectButtonLabel(label, isActive) {
16
10
  const prefix = isActive ? "✅ " : "";
17
11
  const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
@@ -114,7 +108,7 @@ async function buildProjectsKeyboard(projects, page) {
114
108
  }
115
109
  return keyboard;
116
110
  }
117
- async function buildProjectsMenuView(projects, page) {
111
+ export async function buildProjectsMenuView(projects, page) {
118
112
  const currentProject = getCurrentProject();
119
113
  const pageSize = config.bot.projectsListLimit;
120
114
  const { page: normalizedPage, totalPages } = calculateProjectsPaginationRange(projects.length, page, pageSize);
@@ -124,97 +118,3 @@ async function buildProjectsMenuView(projects, page) {
124
118
  keyboard: await buildProjectsKeyboard(projects, normalizedPage),
125
119
  };
126
120
  }
127
- export async function projectsCommand(ctx) {
128
- try {
129
- if (isForegroundBusy()) {
130
- await replyBusyBlocked(ctx);
131
- return;
132
- }
133
- await syncSessionDirectoryCache();
134
- const projects = await getProjects();
135
- if (projects.length === 0) {
136
- await ctx.reply(t("projects.empty"));
137
- return;
138
- }
139
- const { text, keyboard } = await buildProjectsMenuView(projects, 0);
140
- await replyWithInlineMenu(ctx, {
141
- menuKind: "project",
142
- text,
143
- keyboard,
144
- });
145
- }
146
- catch (error) {
147
- logger.error("[Bot] Error fetching projects:", error);
148
- await ctx.reply(t("projects.fetch_error"));
149
- }
150
- }
151
- export async function handleProjectSelect(ctx, deps = {}) {
152
- const callbackQuery = ctx.callbackQuery;
153
- if (!callbackQuery?.data) {
154
- return false;
155
- }
156
- const page = parseProjectPageCallback(callbackQuery.data);
157
- const isProjectSelection = callbackQuery.data.startsWith("project:");
158
- if (page === null && !isProjectSelection) {
159
- return false;
160
- }
161
- if (isForegroundBusy()) {
162
- await replyBusyBlocked(ctx);
163
- return true;
164
- }
165
- if (page !== null) {
166
- const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
167
- if (!isActiveMenu) {
168
- return true;
169
- }
170
- try {
171
- const projects = await getProjects();
172
- if (projects.length === 0) {
173
- await ctx.answerCallbackQuery();
174
- await ctx.reply(t("projects.empty"));
175
- return true;
176
- }
177
- const { text, keyboard } = await buildProjectsMenuView(projects, page);
178
- await ctx.answerCallbackQuery();
179
- await ctx.editMessageText(text, {
180
- reply_markup: appendInlineMenuCancelButton(keyboard, "project"),
181
- });
182
- }
183
- catch (error) {
184
- logger.error("[Bot] Error switching projects page:", error);
185
- await ctx.answerCallbackQuery({ text: t("projects.page_load_error") });
186
- }
187
- return true;
188
- }
189
- const projectId = callbackQuery.data.replace("project:", "");
190
- const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
191
- if (!isActiveMenu) {
192
- return true;
193
- }
194
- try {
195
- const projects = await getProjects();
196
- const selectedProject = projects.find((p) => p.id === projectId);
197
- if (!selectedProject) {
198
- throw new Error(`Project with id ${projectId} not found`);
199
- }
200
- const projectName = selectedProject.name || selectedProject.worktree;
201
- logger.info(`[Bot] Project selected: ${projectName} (id: ${projectId})`);
202
- const keyboard = deps.ensureEventSubscription
203
- ? await switchToProject(ctx, selectedProject, "project_switched", {
204
- ensureEventSubscription: deps.ensureEventSubscription,
205
- })
206
- : await switchToProject(ctx, selectedProject, "project_switched");
207
- await ctx.answerCallbackQuery();
208
- await ctx.reply(t("projects.selected", { project: projectName }), {
209
- reply_markup: keyboard,
210
- });
211
- await ctx.deleteMessage();
212
- }
213
- catch (error) {
214
- clearAllInteractionState("project_select_error");
215
- logger.error("[Bot] Error selecting project:", error);
216
- await ctx.answerCallbackQuery();
217
- await ctx.reply(t("projects.select_error"));
218
- }
219
- return true;
220
- }
@@ -1,14 +1,14 @@
1
1
  import { InlineKeyboard } from "grammy";
2
- import { questionManager } from "../../question/manager.js";
2
+ import { questionManager } from "../../app/managers/question-manager.js";
3
3
  import { opencodeClient } from "../../opencode/client.js";
4
- import { getCurrentProject } from "../../settings/manager.js";
5
- import { getCurrentSession } from "../../session/manager.js";
6
- import { summaryAggregator } from "../../summary/aggregator.js";
7
- import { interactionManager } from "../../interaction/manager.js";
4
+ import { getCurrentProject } from "../../app/stores/settings-store.js";
5
+ import { getCurrentSession } from "../../app/services/session-service.js";
6
+ import { summaryAggregator } from "../../app/managers/summary-aggregation-manager.js";
7
+ import { interactionManager } from "../../app/managers/interaction-manager.js";
8
8
  import { logger } from "../../utils/logger.js";
9
9
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
10
10
  import { t } from "../../i18n/index.js";
11
- import { editRenderedBotPart, sendRenderedBotPart } from "../utils/telegram-text.js";
11
+ import { editRenderedBotPart, sendRenderedBotPart } from "../messages/telegram-text.js";
12
12
  const MAX_BUTTON_LENGTH = 60;
13
13
  const TELEGRAM_MESSAGE_LIMIT = 4096;
14
14
  const TRUNCATION_SUFFIX = "…";
@@ -21,13 +21,13 @@ function getCallbackMessageId(ctx) {
21
21
  const messageId = message.message_id;
22
22
  return typeof messageId === "number" ? messageId : null;
23
23
  }
24
- function clearQuestionInteraction(reason) {
24
+ export function clearQuestionInteraction(reason) {
25
25
  const state = interactionManager.getSnapshot();
26
26
  if (state?.kind === "question") {
27
27
  interactionManager.clear(reason);
28
28
  }
29
29
  }
30
- function syncQuestionInteractionState(expectedInput, questionIndex, messageId) {
30
+ export function syncQuestionInteractionState(expectedInput, questionIndex, messageId) {
31
31
  const metadata = {
32
32
  questionIndex,
33
33
  inputMode: expectedInput === "mixed" ? "custom" : "options",
@@ -53,138 +53,7 @@ function syncQuestionInteractionState(expectedInput, questionIndex, messageId) {
53
53
  metadata,
54
54
  });
55
55
  }
56
- export async function handleQuestionCallback(ctx) {
57
- const data = ctx.callbackQuery?.data;
58
- if (!data)
59
- return false;
60
- if (!data.startsWith("question:")) {
61
- return false;
62
- }
63
- logger.debug(`[QuestionHandler] Received callback: ${data}`);
64
- if (!questionManager.isActive()) {
65
- clearQuestionInteraction("question_inactive_callback");
66
- await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
67
- return true;
68
- }
69
- const callbackMessageId = getCallbackMessageId(ctx);
70
- if (!questionManager.isActiveMessage(callbackMessageId)) {
71
- await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
72
- return true;
73
- }
74
- const parts = data.split(":");
75
- const action = parts[1];
76
- const questionIndex = parseInt(parts[2], 10);
77
- if (Number.isNaN(questionIndex) || questionIndex !== questionManager.getCurrentIndex()) {
78
- await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
79
- return true;
80
- }
81
- try {
82
- switch (action) {
83
- case "select":
84
- {
85
- const optionIndex = parseInt(parts[3], 10);
86
- if (Number.isNaN(optionIndex)) {
87
- await ctx.answerCallbackQuery({
88
- text: t("question.processing_error_callback"),
89
- show_alert: true,
90
- });
91
- break;
92
- }
93
- await handleSelectOption(ctx, questionIndex, optionIndex);
94
- }
95
- break;
96
- case "submit":
97
- await handleSubmitAnswer(ctx, questionIndex);
98
- break;
99
- case "custom":
100
- await handleCustomAnswer(ctx, questionIndex);
101
- break;
102
- case "cancel":
103
- await handleCancelPoll(ctx);
104
- break;
105
- default:
106
- await ctx.answerCallbackQuery({
107
- text: t("question.processing_error_callback"),
108
- show_alert: true,
109
- });
110
- break;
111
- }
112
- }
113
- catch (err) {
114
- logger.error("[QuestionHandler] Error handling callback:", err);
115
- await ctx.answerCallbackQuery({
116
- text: t("question.processing_error_callback"),
117
- show_alert: true,
118
- });
119
- }
120
- return true;
121
- }
122
- async function handleSelectOption(ctx, questionIndex, optionIndex) {
123
- logger.debug(`[QuestionHandler] handleSelectOption: qIndex=${questionIndex}, oIndex=${optionIndex}`);
124
- const question = questionManager.getCurrentQuestion();
125
- if (!question) {
126
- logger.debug("[QuestionHandler] No current question");
127
- return;
128
- }
129
- if (questionManager.isWaitingForCustomInput(questionIndex)) {
130
- questionManager.clearCustomInput();
131
- syncQuestionInteractionState("callback", questionIndex, questionManager.getActiveMessageId());
132
- }
133
- questionManager.selectOption(questionIndex, optionIndex);
134
- if (question.multiple) {
135
- logger.debug("[QuestionHandler] Multiple choice mode, updating message");
136
- await updateQuestionMessage(ctx);
137
- await ctx.answerCallbackQuery();
138
- }
139
- else {
140
- logger.debug("[QuestionHandler] Single choice mode, moving to next question");
141
- await ctx.answerCallbackQuery();
142
- const answer = questionManager.getSelectedAnswer(questionIndex);
143
- logger.debug(`[QuestionHandler] Selected answer for question ${questionIndex}: ${answer}`);
144
- // Delete the question message before showing the next one
145
- await ctx.deleteMessage().catch(() => { });
146
- // DO NOT send the answer immediately - move to the next question
147
- // All answers will be sent together after the user answers all questions
148
- await showNextQuestion(ctx);
149
- }
150
- }
151
- async function handleSubmitAnswer(ctx, questionIndex) {
152
- if (questionManager.isWaitingForCustomInput(questionIndex)) {
153
- questionManager.clearCustomInput();
154
- syncQuestionInteractionState("callback", questionIndex, questionManager.getActiveMessageId());
155
- }
156
- const answer = questionManager.getSelectedAnswer(questionIndex);
157
- if (!answer) {
158
- await ctx.answerCallbackQuery({
159
- text: t("question.select_one_required_callback"),
160
- show_alert: true,
161
- });
162
- return;
163
- }
164
- logger.debug(`[QuestionHandler] Submit answer for question ${questionIndex}: ${answer}`);
165
- await ctx.answerCallbackQuery();
166
- // Delete the question message before showing the next one
167
- await ctx.deleteMessage().catch(() => { });
168
- // DO NOT send the answer immediately - move to the next question
169
- // All answers will be sent together after the user answers all questions
170
- await showNextQuestion(ctx);
171
- }
172
- async function handleCustomAnswer(ctx, questionIndex) {
173
- questionManager.startCustomInput(questionIndex);
174
- syncQuestionInteractionState("mixed", questionIndex, questionManager.getActiveMessageId());
175
- await ctx.answerCallbackQuery({
176
- text: t("question.enter_custom_callback"),
177
- show_alert: true,
178
- });
179
- }
180
- async function handleCancelPoll(ctx) {
181
- questionManager.cancel();
182
- clearQuestionInteraction("question_cancelled");
183
- await ctx.editMessageText(t("question.cancelled")).catch(() => { });
184
- await ctx.answerCallbackQuery();
185
- questionManager.clear();
186
- }
187
- async function updateQuestionMessage(ctx) {
56
+ export async function updateQuestionMessage(ctx) {
188
57
  const question = questionManager.getCurrentQuestion();
189
58
  if (!question) {
190
59
  logger.debug("[QuestionHandler] updateQuestionMessage: no current question");
@@ -248,32 +117,7 @@ export async function showCurrentQuestion(bot, chatId) {
248
117
  throw err;
249
118
  }
250
119
  }
251
- export async function handleQuestionTextAnswer(ctx) {
252
- const text = ctx.message?.text;
253
- if (!text)
254
- return;
255
- const currentIndex = questionManager.getCurrentIndex();
256
- if (!questionManager.isWaitingForCustomInput(currentIndex)) {
257
- await ctx.reply(t("question.use_custom_button_first"));
258
- return;
259
- }
260
- if (questionManager.hasCustomAnswer(currentIndex)) {
261
- await ctx.reply(t("question.answer_already_received"));
262
- return;
263
- }
264
- logger.debug(`[QuestionHandler] Custom text answer for question ${currentIndex}: ${text}`);
265
- questionManager.setCustomAnswer(currentIndex, text);
266
- questionManager.clearCustomInput();
267
- // Delete the previous question message
268
- const activeMessageId = questionManager.getActiveMessageId();
269
- if (activeMessageId !== null && ctx.chat) {
270
- await ctx.api.deleteMessage(ctx.chat.id, activeMessageId).catch(() => { });
271
- }
272
- // DO NOT send the answer immediately - move to the next question
273
- // All answers will be sent together after the user answers all questions
274
- await showNextQuestion(ctx);
275
- }
276
- async function showNextQuestion(ctx) {
120
+ export async function showNextQuestion(ctx) {
277
121
  questionManager.nextQuestion();
278
122
  if (!ctx.chat) {
279
123
  return;
@@ -0,0 +1,6 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { t } from "../../i18n/index.js";
3
+ export const RENAME_CANCEL_CALLBACK = "rename:cancel";
4
+ export function buildRenameCancelKeyboard() {
5
+ return new InlineKeyboard().text(t("rename.button.cancel"), RENAME_CANCEL_CALLBACK);
6
+ }
@@ -0,0 +1,42 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { t } from "../../i18n/index.js";
3
+ import { formatTaskListBadge } from "../../app/formatters/scheduled-task-display-formatter.js";
4
+ export const TASK_RETRY_SCHEDULE_CALLBACK = "task:retry-schedule";
5
+ export const TASK_CANCEL_CALLBACK = "task:cancel";
6
+ export const TASKLIST_CALLBACK_PREFIX = "tasklist:";
7
+ export const TASKLIST_OPEN_PREFIX = `${TASKLIST_CALLBACK_PREFIX}open:`;
8
+ export const TASKLIST_DELETE_PREFIX = `${TASKLIST_CALLBACK_PREFIX}delete:`;
9
+ export const TASKLIST_CANCEL_CALLBACK = `${TASKLIST_CALLBACK_PREFIX}cancel`;
10
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
11
+ function truncateText(text, maxLength) {
12
+ if (text.length <= maxLength) {
13
+ return text;
14
+ }
15
+ return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
16
+ }
17
+ function formatTaskButtonLabel(task) {
18
+ const prefix = `[${formatTaskListBadge(task)}]`;
19
+ const prompt = task.prompt.replace(/\s+/g, " ").trim();
20
+ return truncateText(`${prefix} ${prompt}`, MAX_INLINE_BUTTON_LABEL_LENGTH);
21
+ }
22
+ export function buildRetryScheduleKeyboard() {
23
+ return new InlineKeyboard()
24
+ .text(t("task.button.retry_schedule"), TASK_RETRY_SCHEDULE_CALLBACK)
25
+ .text(t("task.button.cancel"), TASK_CANCEL_CALLBACK);
26
+ }
27
+ export function buildCancelKeyboard() {
28
+ return new InlineKeyboard().text(t("task.button.cancel"), TASK_CANCEL_CALLBACK);
29
+ }
30
+ export function buildTaskListKeyboard(tasks) {
31
+ const keyboard = new InlineKeyboard();
32
+ tasks.forEach((task) => {
33
+ keyboard.text(formatTaskButtonLabel(task), `${TASKLIST_OPEN_PREFIX}${task.id}`).row();
34
+ });
35
+ keyboard.text(t("tasklist.button.cancel"), TASKLIST_CANCEL_CALLBACK);
36
+ return keyboard;
37
+ }
38
+ export function buildTaskDetailsKeyboard(taskId) {
39
+ return new InlineKeyboard()
40
+ .text(t("tasklist.button.delete"), `${TASKLIST_DELETE_PREFIX}${taskId}`)
41
+ .text(t("tasklist.button.cancel"), TASKLIST_CANCEL_CALLBACK);
42
+ }