@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,114 @@
1
+ import { permissionManager } from "../../app/managers/permission-manager.js";
2
+ import { opencodeClient } from "../../opencode/client.js";
3
+ import { getCurrentProject } from "../../app/stores/settings-store.js";
4
+ import { getCurrentSession } from "../../app/services/session-service.js";
5
+ import { summaryAggregator } from "../../app/managers/summary-aggregation-manager.js";
6
+ import { clearPermissionInteraction, syncPermissionInteractionState } from "../menus/permission-menu.js";
7
+ import { t } from "../../i18n/index.js";
8
+ import { logger } from "../../utils/logger.js";
9
+ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
10
+ function getCallbackMessageId(ctx) {
11
+ const message = ctx.callbackQuery?.message;
12
+ if (!message || !("message_id" in message)) {
13
+ return null;
14
+ }
15
+ const messageId = message.message_id;
16
+ return typeof messageId === "number" ? messageId : null;
17
+ }
18
+ function isPermissionReply(value) {
19
+ return value === "once" || value === "always" || value === "reject";
20
+ }
21
+ export async function handlePermissionCallback(ctx) {
22
+ const data = ctx.callbackQuery?.data;
23
+ if (!data)
24
+ return false;
25
+ if (!data.startsWith("permission:")) {
26
+ return false;
27
+ }
28
+ logger.debug(`[PermissionHandler] Received callback: ${data}`);
29
+ if (!permissionManager.isActive()) {
30
+ clearPermissionInteraction("permission_inactive_callback");
31
+ await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
32
+ return true;
33
+ }
34
+ const callbackMessageId = getCallbackMessageId(ctx);
35
+ if (!permissionManager.isActiveMessage(callbackMessageId)) {
36
+ await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
37
+ return true;
38
+ }
39
+ const requestID = permissionManager.getRequestID(callbackMessageId);
40
+ if (!requestID) {
41
+ await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
42
+ return true;
43
+ }
44
+ const parts = data.split(":");
45
+ const action = parts[1];
46
+ if (!isPermissionReply(action)) {
47
+ await ctx.answerCallbackQuery({
48
+ text: t("permission.processing_error_callback"),
49
+ show_alert: true,
50
+ });
51
+ return true;
52
+ }
53
+ try {
54
+ await handlePermissionReply(ctx, action, requestID, callbackMessageId);
55
+ }
56
+ catch (err) {
57
+ logger.error("[PermissionHandler] Error handling callback:", err);
58
+ await ctx.answerCallbackQuery({
59
+ text: t("permission.processing_error_callback"),
60
+ show_alert: true,
61
+ });
62
+ }
63
+ return true;
64
+ }
65
+ async function handlePermissionReply(ctx, reply, requestID, callbackMessageId) {
66
+ const currentProject = getCurrentProject();
67
+ const currentSession = getCurrentSession();
68
+ const chatId = ctx.chat?.id;
69
+ const directory = currentSession?.directory ?? currentProject?.worktree;
70
+ if (!directory || !chatId) {
71
+ permissionManager.clear();
72
+ clearPermissionInteraction("permission_invalid_runtime_context");
73
+ await ctx.answerCallbackQuery({
74
+ text: t("permission.no_active_request_callback"),
75
+ show_alert: true,
76
+ });
77
+ return;
78
+ }
79
+ const replyLabels = {
80
+ once: t("permission.reply.once"),
81
+ always: t("permission.reply.always"),
82
+ reject: t("permission.reply.reject"),
83
+ };
84
+ await ctx.answerCallbackQuery({ text: replyLabels[reply] });
85
+ await ctx.deleteMessage().catch(() => { });
86
+ summaryAggregator.stopTypingIndicator();
87
+ logger.info(`[PermissionHandler] Sending permission reply: ${reply}, requestID=${requestID}`);
88
+ safeBackgroundTask({
89
+ taskName: "permission.reply",
90
+ task: () => opencodeClient.permission.reply({
91
+ requestID,
92
+ directory,
93
+ reply,
94
+ }),
95
+ onSuccess: ({ error }) => {
96
+ if (error) {
97
+ logger.error("[PermissionHandler] Failed to send permission reply:", error);
98
+ if (ctx.api && chatId) {
99
+ void ctx.api.sendMessage(chatId, t("permission.send_reply_error")).catch(() => { });
100
+ }
101
+ return;
102
+ }
103
+ logger.info("[PermissionHandler] Permission reply sent successfully");
104
+ },
105
+ });
106
+ permissionManager.removeByMessageId(callbackMessageId);
107
+ if (!permissionManager.isActive()) {
108
+ clearPermissionInteraction("permission_replied");
109
+ return;
110
+ }
111
+ syncPermissionInteractionState({
112
+ lastRepliedRequestID: requestID,
113
+ });
114
+ }
@@ -0,0 +1,79 @@
1
+ import { clearAllInteractionState } from "../../app/managers/interaction-manager.js";
2
+ import { getProjects } from "../../app/services/project-service.js";
3
+ import { isForegroundBusy } from "../../app/services/run-control-service.js";
4
+ import { switchToProject } from "../../app/services/project-switch-service.js";
5
+ import { t } from "../../i18n/index.js";
6
+ import { logger } from "../../utils/logger.js";
7
+ import { appendInlineMenuCancelButton, ensureActiveInlineMenu } from "../menus/inline-menu.js";
8
+ import { buildProjectsMenuView, parseProjectPageCallback } from "../menus/project-selection-menu.js";
9
+ import { replyBusyBlocked } from "../messages/busy-blocked-renderer.js";
10
+ import { createProjectSwitchPresentation } from "../services/project-switch-presentation.js";
11
+ export async function handleProjectSelect(ctx, deps = {}) {
12
+ const callbackQuery = ctx.callbackQuery;
13
+ if (!callbackQuery?.data) {
14
+ return false;
15
+ }
16
+ const page = parseProjectPageCallback(callbackQuery.data);
17
+ const isProjectSelection = callbackQuery.data.startsWith("project:");
18
+ if (page === null && !isProjectSelection) {
19
+ return false;
20
+ }
21
+ if (isForegroundBusy()) {
22
+ await replyBusyBlocked(ctx);
23
+ return true;
24
+ }
25
+ if (page !== null) {
26
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
27
+ if (!isActiveMenu) {
28
+ return true;
29
+ }
30
+ try {
31
+ const projects = await getProjects();
32
+ if (projects.length === 0) {
33
+ await ctx.answerCallbackQuery();
34
+ await ctx.reply(t("projects.empty"));
35
+ return true;
36
+ }
37
+ const { text, keyboard } = await buildProjectsMenuView(projects, page);
38
+ await ctx.answerCallbackQuery();
39
+ await ctx.editMessageText(text, {
40
+ reply_markup: appendInlineMenuCancelButton(keyboard, "project"),
41
+ });
42
+ }
43
+ catch (error) {
44
+ logger.error("[Bot] Error switching projects page:", error);
45
+ await ctx.answerCallbackQuery({ text: t("projects.page_load_error") });
46
+ }
47
+ return true;
48
+ }
49
+ const projectId = callbackQuery.data.replace("project:", "");
50
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
51
+ if (!isActiveMenu) {
52
+ return true;
53
+ }
54
+ try {
55
+ const projects = await getProjects();
56
+ const selectedProject = projects.find((p) => p.id === projectId);
57
+ if (!selectedProject) {
58
+ throw new Error(`Project with id ${projectId} not found`);
59
+ }
60
+ const projectName = selectedProject.name || selectedProject.worktree;
61
+ logger.info(`[Bot] Project selected: ${projectName} (id: ${projectId})`);
62
+ const keyboard = await switchToProject(ctx, selectedProject, "project_switched", {
63
+ ensureEventSubscription: deps.ensureEventSubscription,
64
+ presentation: createProjectSwitchPresentation(),
65
+ });
66
+ await ctx.answerCallbackQuery();
67
+ await ctx.reply(t("projects.selected", { project: projectName }), {
68
+ reply_markup: keyboard,
69
+ });
70
+ await ctx.deleteMessage();
71
+ }
72
+ catch (error) {
73
+ clearAllInteractionState("project_select_error");
74
+ logger.error("[Bot] Error selecting project:", error);
75
+ await ctx.answerCallbackQuery();
76
+ await ctx.reply(t("projects.select_error"));
77
+ }
78
+ return true;
79
+ }
@@ -0,0 +1,159 @@
1
+ import { questionManager } from "../../app/managers/question-manager.js";
2
+ import { clearQuestionInteraction, showNextQuestion, syncQuestionInteractionState, updateQuestionMessage, } from "../menus/question-menu.js";
3
+ import { t } from "../../i18n/index.js";
4
+ import { logger } from "../../utils/logger.js";
5
+ function getCallbackMessageId(ctx) {
6
+ const message = ctx.callbackQuery?.message;
7
+ if (!message || !("message_id" in message)) {
8
+ return null;
9
+ }
10
+ const messageId = message.message_id;
11
+ return typeof messageId === "number" ? messageId : null;
12
+ }
13
+ export async function handleQuestionCallback(ctx) {
14
+ const data = ctx.callbackQuery?.data;
15
+ if (!data)
16
+ return false;
17
+ if (!data.startsWith("question:")) {
18
+ return false;
19
+ }
20
+ logger.debug(`[QuestionHandler] Received callback: ${data}`);
21
+ if (!questionManager.isActive()) {
22
+ clearQuestionInteraction("question_inactive_callback");
23
+ await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
24
+ return true;
25
+ }
26
+ const callbackMessageId = getCallbackMessageId(ctx);
27
+ if (!questionManager.isActiveMessage(callbackMessageId)) {
28
+ await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
29
+ return true;
30
+ }
31
+ const parts = data.split(":");
32
+ const action = parts[1];
33
+ const questionIndex = parseInt(parts[2], 10);
34
+ if (Number.isNaN(questionIndex) || questionIndex !== questionManager.getCurrentIndex()) {
35
+ await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
36
+ return true;
37
+ }
38
+ try {
39
+ switch (action) {
40
+ case "select":
41
+ {
42
+ const optionIndex = parseInt(parts[3], 10);
43
+ if (Number.isNaN(optionIndex)) {
44
+ await ctx.answerCallbackQuery({
45
+ text: t("question.processing_error_callback"),
46
+ show_alert: true,
47
+ });
48
+ break;
49
+ }
50
+ await handleSelectOption(ctx, questionIndex, optionIndex);
51
+ }
52
+ break;
53
+ case "submit":
54
+ await handleSubmitAnswer(ctx, questionIndex);
55
+ break;
56
+ case "custom":
57
+ await handleCustomAnswer(ctx, questionIndex);
58
+ break;
59
+ case "cancel":
60
+ await handleCancelPoll(ctx);
61
+ break;
62
+ default:
63
+ await ctx.answerCallbackQuery({
64
+ text: t("question.processing_error_callback"),
65
+ show_alert: true,
66
+ });
67
+ break;
68
+ }
69
+ }
70
+ catch (err) {
71
+ logger.error("[QuestionHandler] Error handling callback:", err);
72
+ await ctx.answerCallbackQuery({
73
+ text: t("question.processing_error_callback"),
74
+ show_alert: true,
75
+ });
76
+ }
77
+ return true;
78
+ }
79
+ async function handleSelectOption(ctx, questionIndex, optionIndex) {
80
+ logger.debug(`[QuestionHandler] handleSelectOption: qIndex=${questionIndex}, oIndex=${optionIndex}`);
81
+ const question = questionManager.getCurrentQuestion();
82
+ if (!question) {
83
+ logger.debug("[QuestionHandler] No current question");
84
+ return;
85
+ }
86
+ if (questionManager.isWaitingForCustomInput(questionIndex)) {
87
+ questionManager.clearCustomInput();
88
+ syncQuestionInteractionState("callback", questionIndex, questionManager.getActiveMessageId());
89
+ }
90
+ questionManager.selectOption(questionIndex, optionIndex);
91
+ if (question.multiple) {
92
+ logger.debug("[QuestionHandler] Multiple choice mode, updating message");
93
+ await updateQuestionMessage(ctx);
94
+ await ctx.answerCallbackQuery();
95
+ }
96
+ else {
97
+ logger.debug("[QuestionHandler] Single choice mode, moving to next question");
98
+ await ctx.answerCallbackQuery();
99
+ const answer = questionManager.getSelectedAnswer(questionIndex);
100
+ logger.debug(`[QuestionHandler] Selected answer for question ${questionIndex}: ${answer}`);
101
+ await ctx.deleteMessage().catch(() => { });
102
+ await showNextQuestion(ctx);
103
+ }
104
+ }
105
+ async function handleSubmitAnswer(ctx, questionIndex) {
106
+ if (questionManager.isWaitingForCustomInput(questionIndex)) {
107
+ questionManager.clearCustomInput();
108
+ syncQuestionInteractionState("callback", questionIndex, questionManager.getActiveMessageId());
109
+ }
110
+ const answer = questionManager.getSelectedAnswer(questionIndex);
111
+ if (!answer) {
112
+ await ctx.answerCallbackQuery({
113
+ text: t("question.select_one_required_callback"),
114
+ show_alert: true,
115
+ });
116
+ return;
117
+ }
118
+ logger.debug(`[QuestionHandler] Submit answer for question ${questionIndex}: ${answer}`);
119
+ await ctx.answerCallbackQuery();
120
+ await ctx.deleteMessage().catch(() => { });
121
+ await showNextQuestion(ctx);
122
+ }
123
+ async function handleCustomAnswer(ctx, questionIndex) {
124
+ questionManager.startCustomInput(questionIndex);
125
+ syncQuestionInteractionState("mixed", questionIndex, questionManager.getActiveMessageId());
126
+ await ctx.answerCallbackQuery({
127
+ text: t("question.enter_custom_callback"),
128
+ show_alert: true,
129
+ });
130
+ }
131
+ async function handleCancelPoll(ctx) {
132
+ questionManager.cancel();
133
+ clearQuestionInteraction("question_cancelled");
134
+ await ctx.editMessageText(t("question.cancelled")).catch(() => { });
135
+ await ctx.answerCallbackQuery();
136
+ questionManager.clear();
137
+ }
138
+ export async function handleQuestionTextAnswer(ctx) {
139
+ const text = ctx.message?.text;
140
+ if (!text)
141
+ return;
142
+ const currentIndex = questionManager.getCurrentIndex();
143
+ if (!questionManager.isWaitingForCustomInput(currentIndex)) {
144
+ await ctx.reply(t("question.use_custom_button_first"));
145
+ return;
146
+ }
147
+ if (questionManager.hasCustomAnswer(currentIndex)) {
148
+ await ctx.reply(t("question.answer_already_received"));
149
+ return;
150
+ }
151
+ logger.debug(`[QuestionHandler] Custom text answer for question ${currentIndex}: ${text}`);
152
+ questionManager.setCustomAnswer(currentIndex, text);
153
+ questionManager.clearCustomInput();
154
+ const activeMessageId = questionManager.getActiveMessageId();
155
+ if (activeMessageId !== null && ctx.chat) {
156
+ await ctx.api.deleteMessage(ctx.chat.id, activeMessageId).catch(() => { });
157
+ }
158
+ await showNextQuestion(ctx);
159
+ }
@@ -1,11 +1,11 @@
1
- import { InlineKeyboard } from "grammy";
2
1
  import { opencodeClient } from "../../opencode/client.js";
3
- import { getCurrentSession, setCurrentSession } from "../../session/manager.js";
4
- import { renameManager } from "../../rename/manager.js";
5
- import { interactionManager } from "../../interaction/manager.js";
6
- import { pinnedMessageManager } from "../../pinned/manager.js";
2
+ import { setCurrentSession } from "../../app/services/session-service.js";
3
+ import { renameManager } from "../../app/managers/rename-manager.js";
4
+ import { interactionManager } from "../../app/managers/interaction-manager.js";
5
+ import { pinnedMessageManager } from "../pinned/pinned-message-manager.js";
7
6
  import { logger } from "../../utils/logger.js";
8
7
  import { t } from "../../i18n/index.js";
8
+ import { RENAME_CANCEL_CALLBACK } from "../menus/rename-menu.js";
9
9
  function getCallbackMessageId(ctx) {
10
10
  const message = ctx.callbackQuery?.message;
11
11
  if (!message || !("message_id" in message)) {
@@ -20,37 +20,9 @@ function clearRenameInteraction(reason) {
20
20
  interactionManager.clear(reason);
21
21
  }
22
22
  }
23
- export async function renameCommand(ctx) {
24
- try {
25
- const currentSession = getCurrentSession();
26
- if (!currentSession) {
27
- await ctx.reply(t("rename.no_session"));
28
- return;
29
- }
30
- const keyboard = new InlineKeyboard().text(t("rename.button.cancel"), "rename:cancel");
31
- const message = await ctx.reply(t("rename.prompt", { title: currentSession.title }), {
32
- reply_markup: keyboard,
33
- });
34
- renameManager.startWaiting(currentSession.id, currentSession.directory, currentSession.title);
35
- renameManager.setMessageId(message.message_id);
36
- interactionManager.start({
37
- kind: "rename",
38
- expectedInput: "text",
39
- metadata: {
40
- sessionId: currentSession.id,
41
- messageId: message.message_id,
42
- },
43
- });
44
- logger.info(`[RenameCommand] Waiting for new title for session: ${currentSession.id}`);
45
- }
46
- catch (error) {
47
- logger.error("[RenameCommand] Error starting rename flow:", error);
48
- await ctx.reply(t("rename.error"));
49
- }
50
- }
51
23
  export async function handleRenameCancel(ctx) {
52
24
  const data = ctx.callbackQuery?.data;
53
- if (!data || data !== "rename:cancel") {
25
+ if (!data || data !== RENAME_CANCEL_CALLBACK) {
54
26
  return false;
55
27
  }
56
28
  logger.debug("[RenameHandler] Cancel callback received");
@@ -1,15 +1,10 @@
1
- import { InlineKeyboard } from "grammy";
2
1
  import { getDateLocale, t } from "../../i18n/index.js";
3
- import { interactionManager } from "../../interaction/manager.js";
4
- import { formatTaskListBadge } from "../../scheduled-task/display.js";
5
- import { scheduledTaskRuntime } from "../../scheduled-task/runtime.js";
6
- import { getScheduledTask, listScheduledTasks, removeScheduledTask, } from "../../scheduled-task/store.js";
2
+ import { interactionManager } from "../../app/managers/interaction-manager.js";
3
+ import { taskCreationManager } from "../../app/managers/scheduled-task-creation-manager.js";
4
+ import { getScheduledTask, removeScheduledTask } from "../../app/stores/scheduled-task-store.js";
5
+ import { scheduledTaskRuntime } from "../../app/services/scheduled-task-runtime-service.js";
7
6
  import { logger } from "../../utils/logger.js";
8
- const TASKLIST_CALLBACK_PREFIX = "tasklist:";
9
- const TASKLIST_OPEN_PREFIX = `${TASKLIST_CALLBACK_PREFIX}open:`;
10
- const TASKLIST_DELETE_PREFIX = `${TASKLIST_CALLBACK_PREFIX}delete:`;
11
- const TASKLIST_CANCEL_CALLBACK = `${TASKLIST_CALLBACK_PREFIX}cancel`;
12
- const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
7
+ import { buildCancelKeyboard, buildTaskDetailsKeyboard, TASK_CANCEL_CALLBACK, TASK_RETRY_SCHEDULE_CALLBACK, TASKLIST_CALLBACK_PREFIX, TASKLIST_CANCEL_CALLBACK, TASKLIST_DELETE_PREFIX, TASKLIST_OPEN_PREFIX, } from "../menus/scheduled-task-menu.js";
13
8
  function getCallbackMessageId(ctx) {
14
9
  const message = ctx.callbackQuery?.message;
15
10
  if (!message || !("message_id" in message)) {
@@ -18,6 +13,41 @@ function getCallbackMessageId(ctx) {
18
13
  const messageId = message.message_id;
19
14
  return typeof messageId === "number" ? messageId : null;
20
15
  }
16
+ async function deleteMessageIfPresent(ctx, messageId) {
17
+ if (!ctx.chat || typeof messageId !== "number") {
18
+ return;
19
+ }
20
+ await ctx.api.deleteMessage(ctx.chat.id, messageId).catch(() => { });
21
+ }
22
+ function buildTaskInteractionMetadata(stage, projectId, projectWorktree, previewMessageId) {
23
+ return {
24
+ flow: "task",
25
+ stage,
26
+ projectId,
27
+ projectWorktree,
28
+ previewMessageId,
29
+ };
30
+ }
31
+ function isTaskInteraction(state) {
32
+ return state?.kind === "task";
33
+ }
34
+ function clearTaskInteraction(reason) {
35
+ const state = interactionManager.getSnapshot();
36
+ if (state?.kind === "task") {
37
+ interactionManager.clear(reason);
38
+ }
39
+ }
40
+ function clearTaskFlow(reason) {
41
+ taskCreationManager.clear();
42
+ clearTaskInteraction(reason);
43
+ }
44
+ function isTaskCallbackActive(flowState, messageId) {
45
+ return [
46
+ flowState.scheduleRequestMessageId,
47
+ flowState.previewMessageId,
48
+ flowState.promptRequestMessageId,
49
+ ].includes(messageId);
50
+ }
21
51
  function parseTaskListMetadata(state) {
22
52
  if (!state || state.kind !== "custom") {
23
53
  return null;
@@ -55,12 +85,6 @@ function clearTaskListInteraction(reason) {
55
85
  interactionManager.clear(reason);
56
86
  }
57
87
  }
58
- function truncateText(text, maxLength) {
59
- if (text.length <= maxLength) {
60
- return text;
61
- }
62
- return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
63
- }
64
88
  function formatDateTime(dateIso, timezone) {
65
89
  if (!dateIso) {
66
90
  return "-";
@@ -76,37 +100,6 @@ function formatDateTime(dateIso, timezone) {
76
100
  return dateIso;
77
101
  }
78
102
  }
79
- function formatTaskButtonPrefix(task) {
80
- return formatTaskListBadge(task);
81
- }
82
- function formatTaskButtonLabel(task) {
83
- const prefix = `[${formatTaskButtonPrefix(task)}]`;
84
- const prompt = task.prompt.replace(/\s+/g, " ").trim();
85
- return truncateText(`${prefix} ${prompt}`, MAX_INLINE_BUTTON_LABEL_LENGTH);
86
- }
87
- function buildTaskListKeyboard(tasks) {
88
- const keyboard = new InlineKeyboard();
89
- tasks.forEach((task) => {
90
- keyboard.text(formatTaskButtonLabel(task), `${TASKLIST_OPEN_PREFIX}${task.id}`).row();
91
- });
92
- keyboard.text(t("tasklist.button.cancel"), TASKLIST_CANCEL_CALLBACK);
93
- return keyboard;
94
- }
95
- function buildTaskDetailsKeyboard(taskId) {
96
- return new InlineKeyboard()
97
- .text(t("tasklist.button.delete"), `${TASKLIST_DELETE_PREFIX}${taskId}`)
98
- .text(t("tasklist.button.cancel"), TASKLIST_CANCEL_CALLBACK);
99
- }
100
- function sortTasks(tasks) {
101
- return [...tasks].sort((left, right) => {
102
- const leftNextRun = left.nextRunAt ? Date.parse(left.nextRunAt) : Number.POSITIVE_INFINITY;
103
- const rightNextRun = right.nextRunAt ? Date.parse(right.nextRunAt) : Number.POSITIVE_INFINITY;
104
- if (leftNextRun !== rightNextRun) {
105
- return leftNextRun - rightNextRun;
106
- }
107
- return left.createdAt.localeCompare(right.createdAt);
108
- });
109
- }
110
103
  function formatTaskDetails(task) {
111
104
  const variant = task.model.variant ? ` (${task.model.variant})` : "";
112
105
  const model = `${task.model.providerID}/${task.model.modelID}${variant}`;
@@ -122,30 +115,52 @@ function formatTaskDetails(task) {
122
115
  runCount: String(task.runCount),
123
116
  });
124
117
  }
125
- export async function taskListCommand(ctx) {
126
- try {
127
- const tasks = sortTasks(listScheduledTasks());
128
- if (tasks.length === 0) {
129
- await ctx.reply(t("tasklist.empty"));
130
- return;
118
+ export async function handleTaskCallback(ctx) {
119
+ const data = ctx.callbackQuery?.data;
120
+ if (data !== TASK_RETRY_SCHEDULE_CALLBACK && data !== TASK_CANCEL_CALLBACK) {
121
+ return false;
122
+ }
123
+ const flowState = taskCreationManager.getState();
124
+ const interactionState = interactionManager.getSnapshot();
125
+ const callbackMessageId = getCallbackMessageId(ctx);
126
+ if (!flowState ||
127
+ !isTaskInteraction(interactionState) ||
128
+ callbackMessageId === null ||
129
+ !isTaskCallbackActive(flowState, callbackMessageId)) {
130
+ if (!flowState && isTaskInteraction(interactionState)) {
131
+ clearTaskInteraction("task_retry_inactive_state");
131
132
  }
132
- const message = await ctx.reply(t("tasklist.select"), {
133
- reply_markup: buildTaskListKeyboard(tasks),
134
- });
135
- interactionManager.start({
136
- kind: "custom",
137
- expectedInput: "callback",
138
- metadata: {
139
- flow: "tasklist",
140
- stage: "list",
141
- messageId: message.message_id,
142
- },
143
- });
133
+ await ctx.answerCallbackQuery({ text: t("task.inactive_callback"), show_alert: true });
134
+ return true;
144
135
  }
145
- catch (error) {
146
- logger.error("[TaskList] Failed to open task list", error);
147
- await ctx.reply(t("tasklist.load_error"));
136
+ if (data === TASK_CANCEL_CALLBACK) {
137
+ await ctx.answerCallbackQuery({ text: t("task.cancel_callback") });
138
+ await deleteMessageIfPresent(ctx, flowState.scheduleRequestMessageId);
139
+ await deleteMessageIfPresent(ctx, flowState.previewMessageId);
140
+ await deleteMessageIfPresent(ctx, flowState.promptRequestMessageId);
141
+ clearTaskFlow("task_cancelled");
142
+ await ctx.reply(t("task.cancelled"));
143
+ return true;
148
144
  }
145
+ if (!taskCreationManager.isWaitingForPrompt() ||
146
+ callbackMessageId !== flowState.previewMessageId) {
147
+ await ctx.answerCallbackQuery({ text: t("task.inactive_callback"), show_alert: true });
148
+ return true;
149
+ }
150
+ taskCreationManager.resetSchedule();
151
+ interactionManager.transition({
152
+ kind: "task",
153
+ expectedInput: "text",
154
+ metadata: buildTaskInteractionMetadata("awaiting_schedule", flowState.projectId, flowState.projectWorktree),
155
+ });
156
+ await ctx.answerCallbackQuery({ text: t("task.retry_schedule_callback") });
157
+ await deleteMessageIfPresent(ctx, flowState.promptRequestMessageId);
158
+ await deleteMessageIfPresent(ctx, flowState.previewMessageId);
159
+ const message = await ctx.reply(t("task.prompt.schedule"), {
160
+ reply_markup: buildCancelKeyboard(),
161
+ });
162
+ taskCreationManager.setScheduleRequestMessageId(message.message_id);
163
+ return true;
149
164
  }
150
165
  export async function handleTaskListCallback(ctx) {
151
166
  const data = ctx.callbackQuery?.data;