@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
@@ -0,0 +1,115 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { opencodeClient } from "../../opencode/client.js";
3
+ import { getDateLocale, t } from "../../i18n/index.js";
4
+ import { logger } from "../../utils/logger.js";
5
+ export const SESSION_CALLBACK_PREFIX = "session:";
6
+ const SESSION_PAGE_CALLBACK_PREFIX = "session:page:";
7
+ const BACKGROUND_SESSION_CALLBACK_PREFIX = "background-session:";
8
+ const SESSION_FETCH_EXTRA_COUNT = 1;
9
+ const BACKGROUND_SESSION_KIND_CALLBACK_MARKERS = {
10
+ assistant_response: "a",
11
+ question_asked: "q",
12
+ permission_asked: "p",
13
+ };
14
+ const BACKGROUND_SESSION_KIND_BY_CALLBACK_MARKER = {
15
+ a: "assistant_response",
16
+ q: "question_asked",
17
+ p: "permission_asked",
18
+ };
19
+ function buildSessionPageCallback(page) {
20
+ return `${SESSION_PAGE_CALLBACK_PREFIX}${page}`;
21
+ }
22
+ export function parseSessionPageCallback(data) {
23
+ if (!data.startsWith(SESSION_PAGE_CALLBACK_PREFIX)) {
24
+ return null;
25
+ }
26
+ const rawPage = data.slice(SESSION_PAGE_CALLBACK_PREFIX.length);
27
+ const page = Number(rawPage);
28
+ if (!Number.isInteger(page) || page < 0) {
29
+ return null;
30
+ }
31
+ return page;
32
+ }
33
+ export function parseSessionIdCallback(data) {
34
+ if (!data.startsWith(SESSION_CALLBACK_PREFIX)) {
35
+ return null;
36
+ }
37
+ if (data.startsWith(SESSION_PAGE_CALLBACK_PREFIX)) {
38
+ return null;
39
+ }
40
+ const sessionId = data.slice(SESSION_CALLBACK_PREFIX.length);
41
+ return sessionId.length > 0 ? sessionId : null;
42
+ }
43
+ export function parseBackgroundSessionCallback(data) {
44
+ if (!data.startsWith(BACKGROUND_SESSION_CALLBACK_PREFIX)) {
45
+ return null;
46
+ }
47
+ const payload = data.slice(BACKGROUND_SESSION_CALLBACK_PREFIX.length);
48
+ const markerSeparatorIndex = payload.indexOf(":");
49
+ if (markerSeparatorIndex < 0) {
50
+ return payload.length > 0 ? { sessionId: payload, kind: null } : null;
51
+ }
52
+ const marker = payload.slice(0, markerSeparatorIndex);
53
+ const sessionId = payload.slice(markerSeparatorIndex + 1);
54
+ const kind = BACKGROUND_SESSION_KIND_BY_CALLBACK_MARKER[marker];
55
+ if (!kind || sessionId.length === 0) {
56
+ return null;
57
+ }
58
+ return { sessionId, kind };
59
+ }
60
+ export function buildBackgroundSessionOpenKeyboard(sessionId, kind) {
61
+ const marker = BACKGROUND_SESSION_KIND_CALLBACK_MARKERS[kind];
62
+ return new InlineKeyboard().text(t("background.open_session_button"), `${BACKGROUND_SESSION_CALLBACK_PREFIX}${marker}:${sessionId}`);
63
+ }
64
+ function formatSessionsSelectText(page) {
65
+ if (page === 0) {
66
+ return t("sessions.select");
67
+ }
68
+ return t("sessions.select_page", { page: page + 1 });
69
+ }
70
+ export async function loadSessionPage(directory, page, pageSize) {
71
+ const startIndex = page * pageSize;
72
+ const endExclusive = startIndex + pageSize;
73
+ const { data: sessions, error } = await opencodeClient.session.list({
74
+ directory,
75
+ limit: endExclusive + SESSION_FETCH_EXTRA_COUNT,
76
+ roots: true,
77
+ });
78
+ if (error || !sessions) {
79
+ throw error || new Error("No data received from server");
80
+ }
81
+ const hasNext = sessions.length > endExclusive;
82
+ const pagedSessions = sessions.slice(startIndex, endExclusive);
83
+ logger.debug(`[Sessions] Loaded page=${page + 1}, startIndex=${startIndex}, endExclusive=${endExclusive}, pageSize=${pageSize}, items=${pagedSessions.length}, hasNext=${hasNext}`);
84
+ return {
85
+ sessions: pagedSessions,
86
+ hasNext,
87
+ page,
88
+ };
89
+ }
90
+ function buildSessionsKeyboard(pageData, pageSize) {
91
+ const keyboard = new InlineKeyboard();
92
+ const localeForDate = getDateLocale();
93
+ const pageStartIndex = pageData.page * pageSize;
94
+ pageData.sessions.forEach((session, index) => {
95
+ const date = new Date(session.time.created).toLocaleDateString(localeForDate);
96
+ const label = `${pageStartIndex + index + 1}. ${session.title} (${date})`;
97
+ keyboard.text(label, `${SESSION_CALLBACK_PREFIX}${session.id}`).row();
98
+ });
99
+ if (pageData.page > 0) {
100
+ keyboard.text(t("sessions.button.prev_page"), buildSessionPageCallback(pageData.page - 1));
101
+ }
102
+ if (pageData.hasNext) {
103
+ keyboard.text(t("sessions.button.next_page"), buildSessionPageCallback(pageData.page + 1));
104
+ }
105
+ if (pageData.page > 0 || pageData.hasNext) {
106
+ keyboard.row();
107
+ }
108
+ return keyboard;
109
+ }
110
+ export function buildSessionSelectionMenuView(pageData, pageSize) {
111
+ return {
112
+ text: formatSessionsSelectText(pageData.page),
113
+ keyboard: buildSessionsKeyboard(pageData, pageSize),
114
+ };
115
+ }
@@ -0,0 +1,99 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { t } from "../../i18n/index.js";
3
+ export const SKILLS_CALLBACK_PREFIX = "skills:";
4
+ export const SKILLS_CALLBACK_SELECT_PREFIX = `${SKILLS_CALLBACK_PREFIX}select:`;
5
+ const SKILLS_CALLBACK_PAGE_PREFIX = `${SKILLS_CALLBACK_PREFIX}page:`;
6
+ export const SKILLS_CALLBACK_CANCEL = `${SKILLS_CALLBACK_PREFIX}cancel`;
7
+ export const SKILLS_CALLBACK_EXECUTE = `${SKILLS_CALLBACK_PREFIX}execute`;
8
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
9
+ export function formatExecutingSkillMessage(skillName, args) {
10
+ const prefix = t("skills.executing_prefix");
11
+ const skillText = `/${skillName}`;
12
+ const argsSuffix = args ? ` ${args}` : "";
13
+ return {
14
+ text: `${prefix}\n${skillText}${argsSuffix}`,
15
+ entities: [
16
+ {
17
+ type: "code",
18
+ offset: prefix.length + 1,
19
+ length: skillText.length,
20
+ },
21
+ ],
22
+ };
23
+ }
24
+ export function buildSkillPageCallback(page) {
25
+ return `${SKILLS_CALLBACK_PAGE_PREFIX}${page}`;
26
+ }
27
+ export function parseSkillPageCallback(data) {
28
+ if (!data.startsWith(SKILLS_CALLBACK_PAGE_PREFIX)) {
29
+ return null;
30
+ }
31
+ const rawPage = data.slice(SKILLS_CALLBACK_PAGE_PREFIX.length);
32
+ const page = Number(rawPage);
33
+ if (!Number.isInteger(page) || page < 0) {
34
+ return null;
35
+ }
36
+ return page;
37
+ }
38
+ export function parseSkillSelectCallback(data) {
39
+ if (!data.startsWith(SKILLS_CALLBACK_SELECT_PREFIX)) {
40
+ return null;
41
+ }
42
+ const rawIndex = data.slice(SKILLS_CALLBACK_SELECT_PREFIX.length);
43
+ const index = Number(rawIndex);
44
+ if (!Number.isInteger(index) || index < 0) {
45
+ return null;
46
+ }
47
+ return index;
48
+ }
49
+ export function formatSkillsSelectText(page) {
50
+ if (page === 0) {
51
+ return t("skills.select");
52
+ }
53
+ return t("skills.select_page", { page: page + 1 });
54
+ }
55
+ function formatSkillButtonLabel(skill) {
56
+ const description = skill.description?.trim() || t("skills.no_description");
57
+ const rawLabel = `/${skill.name} - ${description}`;
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
+ export function calculateSkillsPaginationRange(totalSkills, page, pageSize) {
64
+ const safePageSize = Math.max(1, pageSize);
65
+ const totalPages = Math.max(1, Math.ceil(totalSkills / safePageSize));
66
+ const normalizedPage = Math.min(Math.max(0, page), totalPages - 1);
67
+ const startIndex = normalizedPage * safePageSize;
68
+ const endIndex = Math.min(startIndex + safePageSize, totalSkills);
69
+ return {
70
+ page: normalizedPage,
71
+ totalPages,
72
+ startIndex,
73
+ endIndex,
74
+ };
75
+ }
76
+ export function buildSkillsListKeyboard(skills, page, pageSize) {
77
+ const keyboard = new InlineKeyboard();
78
+ const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateSkillsPaginationRange(skills.length, page, pageSize);
79
+ skills.slice(startIndex, endIndex).forEach((skill, index) => {
80
+ const globalIndex = startIndex + index;
81
+ keyboard.text(formatSkillButtonLabel(skill), `${SKILLS_CALLBACK_SELECT_PREFIX}${globalIndex}`).row();
82
+ });
83
+ if (totalPages > 1) {
84
+ if (normalizedPage > 0) {
85
+ keyboard.text(t("skills.button.prev_page"), buildSkillPageCallback(normalizedPage - 1));
86
+ }
87
+ if (normalizedPage < totalPages - 1) {
88
+ keyboard.text(t("skills.button.next_page"), buildSkillPageCallback(normalizedPage + 1));
89
+ }
90
+ keyboard.row();
91
+ }
92
+ keyboard.text(t("skills.button.cancel"), SKILLS_CALLBACK_CANCEL);
93
+ return keyboard;
94
+ }
95
+ export function buildSkillsConfirmKeyboard() {
96
+ return new InlineKeyboard()
97
+ .text(t("skills.button.execute"), SKILLS_CALLBACK_EXECUTE)
98
+ .text(t("skills.button.cancel"), SKILLS_CALLBACK_CANCEL);
99
+ }
@@ -0,0 +1,67 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { getStoredModel } from "../../app/services/model-selection-service.js";
3
+ import { formatVariantForDisplay, getAvailableVariants, getCurrentVariant, } from "../../app/services/variant-selection-service.js";
4
+ import { logger } from "../../utils/logger.js";
5
+ import { t } from "../../i18n/index.js";
6
+ import { replyWithInlineMenu } from "./inline-menu.js";
7
+ /**
8
+ * Build inline keyboard with available variants
9
+ * @param currentVariant Current variant for highlighting
10
+ * @param providerID Provider ID
11
+ * @param modelID Model ID
12
+ * @returns InlineKeyboard with variant selection buttons
13
+ */
14
+ export async function buildVariantSelectionMenu(currentVariant, providerID, modelID) {
15
+ const keyboard = new InlineKeyboard();
16
+ const variants = await getAvailableVariants(providerID, modelID);
17
+ if (variants.length === 0) {
18
+ logger.warn("[VariantHandler] No variants found");
19
+ return keyboard;
20
+ }
21
+ // Filter only active variants (not disabled)
22
+ const activeVariants = variants.filter((v) => !v.disabled);
23
+ if (activeVariants.length === 0) {
24
+ logger.warn("[VariantHandler] No active variants found");
25
+ // If no active variants, show default at least
26
+ keyboard.text(`✅ ${formatVariantForDisplay("default")}`, "variant:default").row();
27
+ return keyboard;
28
+ }
29
+ // Add button for each variant (one per row)
30
+ activeVariants.forEach((variant) => {
31
+ const isActive = variant.id === currentVariant;
32
+ const label = formatVariantForDisplay(variant.id);
33
+ const labelWithCheck = isActive ? `✅ ${label}` : label;
34
+ keyboard.text(labelWithCheck, `variant:${variant.id}`).row();
35
+ });
36
+ return keyboard;
37
+ }
38
+ /**
39
+ * Show variant selection menu
40
+ * @param ctx grammY context
41
+ */
42
+ export async function showVariantSelectionMenu(ctx) {
43
+ try {
44
+ const currentModel = getStoredModel();
45
+ if (!currentModel.providerID || !currentModel.modelID) {
46
+ await ctx.reply(t("variant.select_model_first"));
47
+ return;
48
+ }
49
+ const currentVariant = getCurrentVariant();
50
+ const keyboard = await buildVariantSelectionMenu(currentVariant, currentModel.providerID, currentModel.modelID);
51
+ if (keyboard.inline_keyboard.length === 0) {
52
+ await ctx.reply(t("variant.menu.empty"));
53
+ return;
54
+ }
55
+ const displayName = formatVariantForDisplay(currentVariant);
56
+ const text = t("variant.menu.current", { name: displayName });
57
+ await replyWithInlineMenu(ctx, {
58
+ menuKind: "variant",
59
+ text,
60
+ keyboard,
61
+ });
62
+ }
63
+ catch (err) {
64
+ logger.error("[VariantHandler] Error showing variant menu:", err);
65
+ await ctx.reply(t("variant.menu.error"));
66
+ }
67
+ }
@@ -0,0 +1,76 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { config } from "../../config.js";
3
+ import { t } from "../../i18n/index.js";
4
+ import { buildProjectButtonLabel, calculateProjectsPaginationRange, } from "./project-selection-menu.js";
5
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
6
+ export const WORKTREE_CALLBACK_PREFIX = "worktree:";
7
+ const WORKTREE_PAGE_CALLBACK_PREFIX = "worktree:page:";
8
+ function formatWorktreeButtonLabel(label, isActive) {
9
+ const prefix = isActive ? "✅ " : "";
10
+ const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
11
+ if (label.length <= availableLength) {
12
+ return `${prefix}${label}`;
13
+ }
14
+ return `${prefix}${label.slice(0, Math.max(0, availableLength - 3))}...`;
15
+ }
16
+ function buildWorktreeButtonLabel(index, entry) {
17
+ return buildProjectButtonLabel(index, entry.path);
18
+ }
19
+ export function parseWorktreePageCallback(data) {
20
+ if (!data.startsWith(WORKTREE_PAGE_CALLBACK_PREFIX)) {
21
+ return null;
22
+ }
23
+ const rawPage = data.slice(WORKTREE_PAGE_CALLBACK_PREFIX.length);
24
+ if (!/^\d+$/.test(rawPage)) {
25
+ return null;
26
+ }
27
+ return Number.parseInt(rawPage, 10);
28
+ }
29
+ export function parseWorktreeIndexCallback(data) {
30
+ if (!data.startsWith(WORKTREE_CALLBACK_PREFIX) ||
31
+ data.startsWith(WORKTREE_PAGE_CALLBACK_PREFIX)) {
32
+ return null;
33
+ }
34
+ const rawIndex = data.slice(WORKTREE_CALLBACK_PREFIX.length);
35
+ if (!/^\d+$/.test(rawIndex)) {
36
+ return null;
37
+ }
38
+ return Number.parseInt(rawIndex, 10);
39
+ }
40
+ function buildWorktreeMenuText(page, totalPages) {
41
+ const baseText = t("worktree.select_with_current");
42
+ if (totalPages <= 1) {
43
+ return baseText;
44
+ }
45
+ return `${baseText}\n\n${t("projects.page_indicator", {
46
+ current: String(page + 1),
47
+ total: String(totalPages),
48
+ })}`;
49
+ }
50
+ function buildWorktreeKeyboard(worktrees, page) {
51
+ const keyboard = new InlineKeyboard();
52
+ const pageSize = config.bot.projectsListLimit;
53
+ const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateProjectsPaginationRange(worktrees.length, page, pageSize);
54
+ worktrees.slice(startIndex, endIndex).forEach((entry, index) => {
55
+ const label = buildWorktreeButtonLabel(startIndex + index, entry);
56
+ keyboard
57
+ .text(formatWorktreeButtonLabel(label, entry.isCurrent), `${WORKTREE_CALLBACK_PREFIX}${startIndex + index}`)
58
+ .row();
59
+ });
60
+ if (totalPages > 1) {
61
+ if (normalizedPage > 0) {
62
+ keyboard.text(t("projects.prev_page"), `${WORKTREE_PAGE_CALLBACK_PREFIX}${normalizedPage - 1}`);
63
+ }
64
+ if (normalizedPage < totalPages - 1) {
65
+ keyboard.text(t("projects.next_page"), `${WORKTREE_PAGE_CALLBACK_PREFIX}${normalizedPage + 1}`);
66
+ }
67
+ }
68
+ return keyboard;
69
+ }
70
+ export function buildWorktreeMenuView(worktrees, page) {
71
+ const { page: normalizedPage, totalPages } = calculateProjectsPaginationRange(worktrees.length, page, config.bot.projectsListLimit);
72
+ return {
73
+ text: buildWorktreeMenuText(normalizedPage, totalPages),
74
+ keyboard: buildWorktreeKeyboard(worktrees, normalizedPage),
75
+ };
76
+ }
@@ -1,7 +1,7 @@
1
1
  import { config } from "../../config.js";
2
2
  import { logger } from "../../utils/logger.js";
3
- import { chunkTelegramRenderedBlocks } from "../../telegram/render/chunker.js";
4
- import { renderTelegramBlocks, renderTelegramParts } from "../../telegram/render/pipeline.js";
3
+ import { chunkTelegramRenderedBlocks } from "../render/chunker.js";
4
+ import { renderTelegramBlocks, renderTelegramParts } from "../render/pipeline.js";
5
5
  export function createPlainRenderedBlock(text) {
6
6
  return {
7
7
  blockType: "plain",
@@ -0,0 +1,11 @@
1
+ import { t } from "../../i18n/index.js";
2
+ export async function replyBusyBlocked(ctx) {
3
+ const message = t("bot.session_busy");
4
+ if (ctx.callbackQuery) {
5
+ await ctx.answerCallbackQuery({ text: message }).catch(() => { });
6
+ return;
7
+ }
8
+ if (ctx.chat) {
9
+ await ctx.reply(message).catch(() => { });
10
+ }
11
+ }
@@ -0,0 +1,22 @@
1
+ import { buildExternalUserInputNotification, } from "../../app/services/external-user-input-service.js";
2
+ import { sendBotText } from "./telegram-text.js";
3
+ async function sendExternalUserInputNotification(api, chatId, notification) {
4
+ await sendBotText({
5
+ api,
6
+ chatId,
7
+ text: notification.text,
8
+ rawFallbackText: notification.rawFallbackText,
9
+ format: "markdown_v2",
10
+ });
11
+ }
12
+ export async function deliverExternalUserInputNotification({ api, chatId, currentSessionId, sessionId, text, consumeSuppressedInput, }) {
13
+ const notification = buildExternalUserInputNotification(text);
14
+ if (!notification || currentSessionId !== sessionId) {
15
+ return false;
16
+ }
17
+ if (consumeSuppressedInput(sessionId, text)) {
18
+ return false;
19
+ }
20
+ await sendExternalUserInputNotification(api, chatId, notification);
21
+ return true;
22
+ }
@@ -0,0 +1,63 @@
1
+ import { config } from "../../config.js";
2
+ import { escapePlainTextForTelegramMarkdownV2, formatSummaryWithMode, } from "./summary-message-formatter.js";
3
+ import { sendBotText } from "./telegram-text.js";
4
+ const TELEGRAM_MESSAGE_LIMIT = 4096;
5
+ function getScheduledTaskDeliveryFormat() {
6
+ return config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw";
7
+ }
8
+ function getSilentDeliveryOptions() {
9
+ return config.bot.scheduledTaskNotificationsSilent
10
+ ? { options: { disable_notification: true } }
11
+ : {};
12
+ }
13
+ function buildScheduledTaskSuccessMessageParts(delivery) {
14
+ if (!delivery.resultText) {
15
+ return [delivery.notificationText];
16
+ }
17
+ if (config.bot.messageFormatMode !== "markdown") {
18
+ return formatSummaryWithMode(`${delivery.notificationText}\n\n${delivery.resultText}`, config.bot.messageFormatMode);
19
+ }
20
+ const header = escapePlainTextForTelegramMarkdownV2(delivery.notificationText);
21
+ const resultParts = formatSummaryWithMode(delivery.resultText, config.bot.messageFormatMode);
22
+ if (resultParts.length === 0) {
23
+ return [header];
24
+ }
25
+ const firstPart = `${header}\n\n${resultParts[0]}`;
26
+ if (firstPart.length <= TELEGRAM_MESSAGE_LIMIT) {
27
+ return [firstPart, ...resultParts.slice(1)];
28
+ }
29
+ return [header, ...resultParts];
30
+ }
31
+ export function createScheduledTaskDeliverySender(api, chatId) {
32
+ return {
33
+ async send(delivery) {
34
+ const messageParts = delivery.status === "success"
35
+ ? buildScheduledTaskSuccessMessageParts(delivery)
36
+ : [delivery.notificationText];
37
+ const format = delivery.status === "success" ? getScheduledTaskDeliveryFormat() : "raw";
38
+ const suppressResultNotification = delivery.status === "success" && Boolean(delivery.footerText);
39
+ const resultDeliveryOptions = suppressResultNotification && !config.bot.scheduledTaskNotificationsSilent
40
+ ? { options: { disable_notification: true } }
41
+ : getSilentDeliveryOptions();
42
+ for (const part of messageParts) {
43
+ await sendBotText({
44
+ api,
45
+ chatId,
46
+ text: part,
47
+ format,
48
+ ...resultDeliveryOptions,
49
+ });
50
+ }
51
+ if (delivery.status === "success" && delivery.footerText) {
52
+ await sendBotText({
53
+ api,
54
+ chatId,
55
+ text: delivery.footerText,
56
+ format: "raw",
57
+ ...getSilentDeliveryOptions(),
58
+ });
59
+ }
60
+ return true;
61
+ },
62
+ };
63
+ }
@@ -1,7 +1,7 @@
1
1
  import { InputFile } from "grammy";
2
2
  import { promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
- import { formatFileSize } from "./file-download.js";
4
+ import { formatFileSize } from "../../app/services/file-download-service.js";
5
5
  import { logger } from "../../utils/logger.js";
6
6
  import { t } from "../../i18n/index.js";
7
7
  const MAX_FILE_SIZE_MB = 50;
@@ -0,0 +1,130 @@
1
+ import { config } from "../../config.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ import { normalizeMarkdownForTelegramRendering } from "../render/markdown-normalizer.js";
4
+ import { convertToTelegramMarkdownV2 } from "../render/markdown-to-telegram-v2.js";
5
+ const TELEGRAM_MESSAGE_LIMIT = 4096;
6
+ const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
7
+ function endsWithOddTrailingBackslashes(text, start, end) {
8
+ let backslashCount = 0;
9
+ for (let index = end - 1; index >= start; index--) {
10
+ if (text[index] !== "\\") {
11
+ break;
12
+ }
13
+ backslashCount += 1;
14
+ }
15
+ return backslashCount % 2 === 1;
16
+ }
17
+ function resolveSplitEndIndex(text, currentIndex, maxLength, options) {
18
+ const hardLimit = Math.min(text.length, currentIndex + maxLength);
19
+ if (hardLimit >= text.length) {
20
+ return text.length;
21
+ }
22
+ let endIndex = hardLimit;
23
+ const breakPoint = text.lastIndexOf("\n", endIndex);
24
+ if (breakPoint > currentIndex) {
25
+ endIndex = breakPoint + 1;
26
+ }
27
+ if (!options?.avoidTrailingMarkdownEscape) {
28
+ return endIndex;
29
+ }
30
+ while (endIndex > currentIndex && endsWithOddTrailingBackslashes(text, currentIndex, endIndex)) {
31
+ endIndex -= 1;
32
+ }
33
+ return endIndex > currentIndex ? endIndex : hardLimit;
34
+ }
35
+ function splitText(text, maxLength, options) {
36
+ const parts = [];
37
+ let currentIndex = 0;
38
+ while (currentIndex < text.length) {
39
+ const endIndex = resolveSplitEndIndex(text, currentIndex, maxLength, options);
40
+ if (endIndex <= currentIndex) {
41
+ const fallbackEnd = Math.min(text.length, currentIndex + 1);
42
+ parts.push(text.slice(currentIndex, fallbackEnd));
43
+ currentIndex = fallbackEnd;
44
+ continue;
45
+ }
46
+ parts.push(text.slice(currentIndex, endIndex));
47
+ currentIndex = endIndex;
48
+ }
49
+ return parts;
50
+ }
51
+ export function formatSummary(text) {
52
+ return formatSummaryWithMode(text, config.bot.messageFormatMode);
53
+ }
54
+ export function escapePlainTextForTelegramMarkdownV2(text) {
55
+ return text.replace(MARKDOWN_V2_RESERVED_CHARS, "\\$1");
56
+ }
57
+ function formatMarkdownForTelegram(text) {
58
+ try {
59
+ const preprocessed = normalizeMarkdownForTelegramRendering(text);
60
+ return escapeMarkdownV2PipesOutsideCode(convertToTelegramMarkdownV2(preprocessed));
61
+ }
62
+ catch (error) {
63
+ logger.warn("[Formatter] Failed to convert markdown summary, falling back to raw text", error);
64
+ return text;
65
+ }
66
+ }
67
+ function escapeMarkdownV2PipesOutsideCode(text) {
68
+ let result = "";
69
+ let index = 0;
70
+ let inInlineCode = false;
71
+ let inCodeFence = false;
72
+ while (index < text.length) {
73
+ if (text.startsWith("```", index)) {
74
+ result += "```";
75
+ index += 3;
76
+ inCodeFence = !inCodeFence;
77
+ continue;
78
+ }
79
+ const char = text[index];
80
+ if (!inCodeFence && char === "`") {
81
+ inInlineCode = !inInlineCode;
82
+ result += char;
83
+ index += 1;
84
+ continue;
85
+ }
86
+ if (!inCodeFence && !inInlineCode && char === "|" && text[index - 1] !== "\\") {
87
+ result += "\\|";
88
+ index += 1;
89
+ continue;
90
+ }
91
+ result += char;
92
+ index += 1;
93
+ }
94
+ return result;
95
+ }
96
+ export function formatSummaryWithMode(text, mode, maxLength = TELEGRAM_MESSAGE_LIMIT) {
97
+ if (!text || text.trim().length === 0) {
98
+ return [];
99
+ }
100
+ const normalizedMaxLength = Math.max(1, Math.floor(maxLength));
101
+ const rawTextLimit = mode === "raw" ? Math.max(1, normalizedMaxLength - "```\n\n```".length) : normalizedMaxLength;
102
+ const parts = splitText(text, rawTextLimit);
103
+ const formattedParts = [];
104
+ for (const part of parts) {
105
+ const trimmed = part.trim();
106
+ if (!trimmed) {
107
+ continue;
108
+ }
109
+ if (mode === "markdown") {
110
+ const converted = formatMarkdownForTelegram(trimmed);
111
+ const convertedParts = splitText(converted, normalizedMaxLength, {
112
+ avoidTrailingMarkdownEscape: true,
113
+ });
114
+ for (const convertedPart of convertedParts) {
115
+ const normalizedPart = convertedPart.trim();
116
+ if (normalizedPart) {
117
+ formattedParts.push(normalizedPart);
118
+ }
119
+ }
120
+ continue;
121
+ }
122
+ if (parts.length > 1) {
123
+ formattedParts.push(`\`\`\`\n${trimmed}\n\`\`\``);
124
+ }
125
+ else {
126
+ formattedParts.push(trimmed);
127
+ }
128
+ }
129
+ return formattedParts;
130
+ }
@@ -103,6 +103,66 @@ export async function editRenderedBotPart({ api, chatId, messageId, part, option
103
103
  };
104
104
  }
105
105
  }
106
+ export async function sendDraftBotPart({ api, chatId, draftId, part, }) {
107
+ logger.debug("[Bot] Sending draft part", {
108
+ draftId,
109
+ textLength: part.text.length,
110
+ entityCount: part.entities?.length ?? 0,
111
+ });
112
+ if (!part.entities?.length) {
113
+ await api.sendMessageDraft(chatId, draftId, part.text);
114
+ return {
115
+ deliveredSignature: getTelegramRenderedPartSignature({ text: part.text }),
116
+ };
117
+ }
118
+ try {
119
+ await api.sendMessageDraft(chatId, draftId, part.text, {
120
+ entities: part.entities,
121
+ });
122
+ return {
123
+ deliveredSignature: getTelegramRenderedPartSignature(part),
124
+ };
125
+ }
126
+ catch (error) {
127
+ logger.warn("[Bot] Entity draft failed, retrying with raw fallback", error);
128
+ await api.sendMessageDraft(chatId, draftId, part.fallbackText);
129
+ return {
130
+ deliveredSignature: getTelegramRenderedPartSignature({ text: part.fallbackText }),
131
+ };
132
+ }
133
+ }
134
+ export async function completeDraftPart({ api, chatId, part, options, }) {
135
+ const rawOptions = stripRichFormattingOptions(options);
136
+ logger.debug("[Bot] Completing draft with real message", {
137
+ textLength: part.text.length,
138
+ entityCount: part.entities?.length ?? 0,
139
+ });
140
+ if (!part.entities?.length) {
141
+ const sentMessage = await api.sendMessage(chatId, part.text, rawOptions);
142
+ return {
143
+ messageId: sentMessage.message_id,
144
+ deliveredSignature: getTelegramRenderedPartSignature({ text: part.text }),
145
+ };
146
+ }
147
+ try {
148
+ const sentMessage = await api.sendMessage(chatId, part.text, {
149
+ ...(rawOptions || {}),
150
+ entities: part.entities,
151
+ });
152
+ return {
153
+ messageId: sentMessage.message_id,
154
+ deliveredSignature: getTelegramRenderedPartSignature(part),
155
+ };
156
+ }
157
+ catch (error) {
158
+ logger.warn("[Bot] Entity complete failed, retrying with raw fallback", error);
159
+ const sentMessage = await api.sendMessage(chatId, part.fallbackText, rawOptions);
160
+ return {
161
+ messageId: sentMessage.message_id,
162
+ deliveredSignature: getTelegramRenderedPartSignature({ text: part.fallbackText }),
163
+ };
164
+ }
165
+ }
106
166
  export async function editBotText({ api, chatId, messageId, text, rawFallbackText, options, format = "raw", }) {
107
167
  await editMessageWithMarkdownFallback({
108
168
  api,