@grinev/opencode-telegram-bot 0.21.1 → 0.22.0

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 (193) hide show
  1. package/.env.example +10 -15
  2. package/README.md +33 -13
  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} +20 -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} +177 -13
  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} +27 -12
  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} +49 -7
  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 +113 -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/callbacks/settings-callback-handler.js +105 -0
  62. package/dist/bot/{commands/skills.js → callbacks/skills-catalog-callback-handler.js} +8 -188
  63. package/dist/bot/{handlers/variant.js → callbacks/variant-selection-callback-handler.js} +7 -69
  64. package/dist/bot/callbacks/worktree-callback-handler.js +97 -0
  65. package/dist/bot/commands/{abort.js → abort-command.js} +6 -6
  66. package/dist/bot/commands/command-catalog-command.js +42 -0
  67. package/dist/bot/commands/definitions.js +1 -1
  68. package/dist/bot/commands/{detach.js → detach-command.js} +8 -8
  69. package/dist/bot/commands/ls-command.js +48 -0
  70. package/dist/bot/commands/mcp-catalog-command.js +39 -0
  71. package/dist/bot/commands/messages-command.js +59 -0
  72. package/dist/bot/commands/{new.js → new-command.js} +12 -11
  73. package/dist/bot/commands/open-command.js +45 -0
  74. package/dist/bot/commands/{opencode-start.js → opencode-start-command.js} +1 -1
  75. package/dist/bot/commands/{opencode-stop.js → opencode-stop-command.js} +1 -1
  76. package/dist/bot/commands/projects-command.js +32 -0
  77. package/dist/bot/commands/rename-command.js +33 -0
  78. package/dist/bot/commands/sessions-command.js +42 -0
  79. package/dist/bot/commands/settings-command.js +10 -0
  80. package/dist/bot/commands/skills-catalog-command.js +42 -0
  81. package/dist/bot/commands/{start.js → start-command.js} +12 -12
  82. package/dist/bot/commands/{status.js → status-command.js} +15 -10
  83. package/dist/bot/commands/{task.js → task-command.js} +9 -81
  84. package/dist/bot/commands/tasklist-command.js +40 -0
  85. package/dist/bot/commands/worktree-command.js +47 -0
  86. package/dist/bot/handlers/{document.js → document-handler.js} +3 -3
  87. package/dist/bot/handlers/{media-group.js → media-group-handler.js} +3 -3
  88. package/dist/bot/handlers/photo-handler.js +44 -0
  89. package/dist/bot/handlers/prompt.js +17 -17
  90. package/dist/bot/handlers/text-message-handler.js +61 -0
  91. package/dist/bot/handlers/tts-response-handler.js +29 -0
  92. package/dist/bot/handlers/{voice.js → voice-handler.js} +6 -3
  93. package/dist/bot/index.js +27 -1179
  94. package/dist/{keyboard/manager.js → bot/keyboards/keyboard-manager.js} +6 -6
  95. package/dist/bot/keyboards/keyboard-types.js +1 -0
  96. package/dist/bot/{utils/keyboard.js → keyboards/main-reply-keyboard.js} +2 -2
  97. package/dist/bot/menus/agent-selection-menu.js +54 -0
  98. package/dist/bot/menus/command-catalog-menu.js +101 -0
  99. package/dist/bot/menus/context-control-menu.js +33 -0
  100. package/dist/bot/menus/file-browser-menu.js +265 -0
  101. package/dist/bot/{handlers → menus}/inline-menu.js +7 -34
  102. package/dist/bot/menus/mcp-catalog-menu.js +97 -0
  103. package/dist/bot/menus/message-history-menu.js +107 -0
  104. package/dist/bot/menus/model-selection-menu.js +66 -0
  105. package/dist/bot/menus/permission-menu.js +116 -0
  106. package/dist/bot/{commands/projects.js → menus/project-selection-menu.js} +5 -105
  107. package/dist/bot/{handlers/question.js → menus/question-menu.js} +10 -166
  108. package/dist/bot/menus/rename-menu.js +6 -0
  109. package/dist/bot/menus/scheduled-task-menu.js +42 -0
  110. package/dist/bot/menus/session-selection-menu.js +115 -0
  111. package/dist/bot/menus/settings-menu.js +52 -0
  112. package/dist/bot/menus/skills-catalog-menu.js +99 -0
  113. package/dist/bot/menus/variant-selection-menu.js +67 -0
  114. package/dist/bot/menus/worktree-selection-menu.js +76 -0
  115. package/dist/bot/{utils → messages}/assistant-rendering.js +2 -2
  116. package/dist/bot/messages/busy-blocked-renderer.js +11 -0
  117. package/dist/bot/messages/external-user-input-notification.js +22 -0
  118. package/dist/bot/messages/scheduled-task-delivery.js +63 -0
  119. package/dist/bot/{utils → messages}/send-downloaded-file.js +1 -1
  120. package/dist/bot/messages/summary-message-formatter.js +130 -0
  121. package/dist/bot/{utils → messages}/thinking-message.js +1 -4
  122. package/dist/bot/messages/thinking-rendering.js +66 -0
  123. package/dist/{interaction/guard.js → bot/middleware/interaction-guard-decision.js} +11 -4
  124. package/dist/bot/middleware/interaction-guard.js +2 -2
  125. package/dist/bot/middleware/unknown-command.js +1 -1
  126. package/dist/{pinned/format.js → bot/pinned/pinned-message-format.js} +1 -2
  127. package/dist/{pinned/manager.js → bot/pinned/pinned-message-manager.js} +10 -10
  128. package/dist/bot/pinned/pinned-message-types.js +1 -0
  129. package/dist/{summary → bot/render}/markdown-to-telegram-v2.js +2 -2
  130. package/dist/bot/render/types.js +1 -0
  131. package/dist/bot/routers/command-router.js +73 -0
  132. package/dist/bot/routers/message-router.js +150 -0
  133. package/dist/bot/services/attach-presentation.js +40 -0
  134. package/dist/bot/services/event-subscription-service.js +1116 -0
  135. package/dist/bot/services/project-switch-presentation.js +26 -0
  136. package/dist/bot/streaming/compact-progress-streamer.js +162 -0
  137. package/dist/bot/{utils → streaming}/finalize-assistant-response.js +9 -3
  138. package/dist/bot/streaming/response-streamer.js +16 -1
  139. package/dist/cli.js +4 -4
  140. package/dist/config.js +9 -18
  141. package/dist/i18n/ar.js +31 -6
  142. package/dist/i18n/de.js +32 -7
  143. package/dist/i18n/en.js +32 -7
  144. package/dist/i18n/es.js +32 -7
  145. package/dist/i18n/fr.js +32 -7
  146. package/dist/i18n/ru.js +32 -7
  147. package/dist/i18n/zh.js +32 -7
  148. package/dist/index.js +1 -1
  149. package/dist/opencode/ready-refresh.js +2 -2
  150. package/dist/{service → runtime/service}/manager.js +2 -2
  151. package/dist/runtime/service/types.js +1 -0
  152. package/package.json +1 -1
  153. package/dist/bot/commands/ls.js +0 -445
  154. package/dist/bot/commands/open.js +0 -319
  155. package/dist/bot/commands/tts.js +0 -13
  156. package/dist/bot/commands/worktree.js +0 -201
  157. package/dist/bot/handlers/permission.js +0 -235
  158. package/dist/bot/utils/browser-roots.js +0 -140
  159. package/dist/bot/utils/file-tree.js +0 -92
  160. package/dist/bot/utils/send-tts-response.js +0 -37
  161. package/dist/bot/utils/switch-project.js +0 -45
  162. package/dist/bot/utils/telegram-file-url.js +0 -11
  163. package/dist/interaction/busy.js +0 -8
  164. package/dist/interaction/cleanup.js +0 -32
  165. /package/dist/{scheduled-task/display.js → app/formatters/scheduled-task-display-formatter.js} +0 -0
  166. /package/dist/{bot/utils/abort-error-suppression.js → app/managers/abort-suppression-manager.js} +0 -0
  167. /package/dist/{external-input/suppression.js → app/managers/external-input-suppression-manager.js} +0 -0
  168. /package/dist/{scheduled-task/next-run.js → app/services/scheduled-task-next-run-service.js} +0 -0
  169. /package/dist/{git/worktree.js → app/services/worktree-service.js} +0 -0
  170. /package/dist/{agent/types.js → app/types/agent.js} +0 -0
  171. /package/dist/{interaction/types.js → app/types/interaction.js} +0 -0
  172. /package/dist/{model/types.js → app/types/model.js} +0 -0
  173. /package/dist/{keyboard/types.js → app/types/permission.js} +0 -0
  174. /package/dist/{permission/types.js → app/types/project.js} +0 -0
  175. /package/dist/{pinned/types.js → app/types/question.js} +0 -0
  176. /package/dist/{scheduled-task/types.js → app/types/scheduled-task.js} +0 -0
  177. /package/dist/{question/types.js → app/types/session.js} +0 -0
  178. /package/dist/{service/types.js → app/types/settings.js} +0 -0
  179. /package/dist/{telegram/render/types.js → app/types/summary.js} +0 -0
  180. /package/dist/{variant/types.js → app/types/variant.js} +0 -0
  181. /package/dist/bot/commands/{help.js → help-command.js} +0 -0
  182. /package/dist/bot/{utils → messages}/send-with-markdown-fallback.js +0 -0
  183. /package/dist/bot/{utils → messages}/telegram-text.js +0 -0
  184. /package/dist/{telegram → bot}/render/block-fallback.js +0 -0
  185. /package/dist/{telegram → bot}/render/block-parser.js +0 -0
  186. /package/dist/{telegram → bot}/render/block-renderer.js +0 -0
  187. /package/dist/{telegram → bot}/render/chunker.js +0 -0
  188. /package/dist/{telegram → bot}/render/inline-renderer.js +0 -0
  189. /package/dist/{telegram → bot}/render/markdown-normalizer.js +0 -0
  190. /package/dist/{telegram → bot}/render/pipeline.js +0 -0
  191. /package/dist/{telegram → bot}/render/validator.js +0 -0
  192. /package/dist/bot/{utils/commands.js → routers/command-utils.js} +0 -0
  193. /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,52 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { getCompactOutputMode, getResponseStreamingMode, getSendDiffFileAttachments, getShowAssistantRunFooter, getShowThinkingContent, getTtsMode, } from "../../app/stores/settings-store.js";
3
+ import { t } from "../../i18n/index.js";
4
+ export const SETTINGS_CALLBACK_PREFIX = "settings:";
5
+ export const SETTINGS_COMPACT_OUTPUT_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}compact_output`;
6
+ export const SETTINGS_THINKING_CONTENT_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}thinking_content`;
7
+ export const SETTINGS_RESPONSE_STREAMING_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}response_streaming`;
8
+ export const SETTINGS_DIFF_FILES_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}diff_files`;
9
+ export const SETTINGS_ASSISTANT_FOOTER_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}assistant_footer`;
10
+ export const SETTINGS_TTS_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}tts`;
11
+ export function formatBooleanSettingValue(enabled) {
12
+ return enabled ? t("settings.value.on") : t("settings.value.off");
13
+ }
14
+ export function formatTtsModeValue(mode) {
15
+ if (mode === "all") {
16
+ return t("status.tts.all");
17
+ }
18
+ if (mode === "auto") {
19
+ return t("status.tts.auto");
20
+ }
21
+ return t("status.tts.off");
22
+ }
23
+ export function formatResponseStreamingModeValue(mode) {
24
+ return mode === "draft"
25
+ ? t("settings.response_streaming.draft")
26
+ : t("settings.response_streaming.edit");
27
+ }
28
+ export function buildSettingsMenuView() {
29
+ const compactOutputMode = getCompactOutputMode();
30
+ const showThinkingContent = getShowThinkingContent();
31
+ const responseStreamingMode = getResponseStreamingMode();
32
+ const sendDiffFileAttachments = getSendDiffFileAttachments();
33
+ const showAssistantRunFooter = getShowAssistantRunFooter();
34
+ const ttsMode = getTtsMode();
35
+ const keyboard = new InlineKeyboard()
36
+ .text(`${t("settings.compact_output.label")}: ${formatBooleanSettingValue(compactOutputMode)}`, SETTINGS_COMPACT_OUTPUT_CALLBACK);
37
+ if (!compactOutputMode) {
38
+ keyboard.row().text(`${t("settings.thinking_content.label")}: ${formatBooleanSettingValue(showThinkingContent)}`, SETTINGS_THINKING_CONTENT_CALLBACK);
39
+ keyboard.row().text(`${t("settings.diff_files.label")}: ${formatBooleanSettingValue(sendDiffFileAttachments)}`, SETTINGS_DIFF_FILES_CALLBACK);
40
+ }
41
+ keyboard
42
+ .row()
43
+ .text(`${t("settings.response_streaming.label")}: ${formatResponseStreamingModeValue(responseStreamingMode)}`, SETTINGS_RESPONSE_STREAMING_CALLBACK)
44
+ .row()
45
+ .text(`${t("settings.assistant_footer.label")}: ${formatBooleanSettingValue(showAssistantRunFooter)}`, SETTINGS_ASSISTANT_FOOTER_CALLBACK)
46
+ .row()
47
+ .text(`${t("settings.tts.label")}: ${formatTtsModeValue(ttsMode)}`, SETTINGS_TTS_CALLBACK);
48
+ return {
49
+ text: t("settings.menu.title"),
50
+ keyboard,
51
+ };
52
+ }
@@ -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
+ }
@@ -1,8 +1,5 @@
1
1
  import { t } from "../../i18n/index.js";
2
- export function deliverThinkingMessage(sessionId, batcher, options) {
3
- if (options.hideThinkingMessages) {
4
- return;
5
- }
2
+ export function deliverThinkingMessage(sessionId, batcher) {
6
3
  const message = t("bot.thinking");
7
4
  batcher.sendTextNow(sessionId, message, "thinking_started");
8
5
  }