zardbot-telegram 1.0.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 (111) hide show
  1. package/.env.example +116 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/dist/agent/manager.js +88 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +49 -0
  7. package/dist/bot/commands/abort.js +121 -0
  8. package/dist/bot/commands/commands.js +480 -0
  9. package/dist/bot/commands/definitions.js +27 -0
  10. package/dist/bot/commands/help.js +10 -0
  11. package/dist/bot/commands/models.js +38 -0
  12. package/dist/bot/commands/new.js +70 -0
  13. package/dist/bot/commands/opencode-start.js +101 -0
  14. package/dist/bot/commands/opencode-stop.js +44 -0
  15. package/dist/bot/commands/projects.js +223 -0
  16. package/dist/bot/commands/rename.js +139 -0
  17. package/dist/bot/commands/sessions.js +351 -0
  18. package/dist/bot/commands/start.js +43 -0
  19. package/dist/bot/commands/status.js +95 -0
  20. package/dist/bot/commands/task.js +399 -0
  21. package/dist/bot/commands/tasklist.js +220 -0
  22. package/dist/bot/commands/voice.js +145 -0
  23. package/dist/bot/handlers/agent.js +118 -0
  24. package/dist/bot/handlers/context.js +100 -0
  25. package/dist/bot/handlers/document.js +65 -0
  26. package/dist/bot/handlers/inline-menu.js +119 -0
  27. package/dist/bot/handlers/model.js +143 -0
  28. package/dist/bot/handlers/permission.js +235 -0
  29. package/dist/bot/handlers/prompt.js +240 -0
  30. package/dist/bot/handlers/question.js +390 -0
  31. package/dist/bot/handlers/tts.js +89 -0
  32. package/dist/bot/handlers/variant.js +138 -0
  33. package/dist/bot/handlers/voice.js +173 -0
  34. package/dist/bot/index.js +977 -0
  35. package/dist/bot/message-patterns.js +4 -0
  36. package/dist/bot/middleware/auth.js +30 -0
  37. package/dist/bot/middleware/interaction-guard.js +95 -0
  38. package/dist/bot/middleware/unknown-command.js +22 -0
  39. package/dist/bot/streaming/response-streamer.js +286 -0
  40. package/dist/bot/streaming/tool-call-streamer.js +285 -0
  41. package/dist/bot/utils/busy-guard.js +15 -0
  42. package/dist/bot/utils/commands.js +21 -0
  43. package/dist/bot/utils/file-download.js +91 -0
  44. package/dist/bot/utils/finalize-assistant-response.js +52 -0
  45. package/dist/bot/utils/keyboard.js +69 -0
  46. package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
  47. package/dist/bot/utils/telegram-text.js +28 -0
  48. package/dist/bot/utils/thinking-message.js +8 -0
  49. package/dist/cli/args.js +98 -0
  50. package/dist/cli.js +80 -0
  51. package/dist/config.js +97 -0
  52. package/dist/i18n/de.js +357 -0
  53. package/dist/i18n/en.js +357 -0
  54. package/dist/i18n/es.js +357 -0
  55. package/dist/i18n/fr.js +357 -0
  56. package/dist/i18n/index.js +109 -0
  57. package/dist/i18n/ru.js +357 -0
  58. package/dist/i18n/zh.js +357 -0
  59. package/dist/index.js +26 -0
  60. package/dist/interaction/busy.js +8 -0
  61. package/dist/interaction/cleanup.js +32 -0
  62. package/dist/interaction/guard.js +140 -0
  63. package/dist/interaction/manager.js +106 -0
  64. package/dist/interaction/types.js +1 -0
  65. package/dist/keyboard/manager.js +172 -0
  66. package/dist/keyboard/types.js +1 -0
  67. package/dist/model/capabilities.js +62 -0
  68. package/dist/model/context-limit.js +57 -0
  69. package/dist/model/manager.js +259 -0
  70. package/dist/model/types.js +24 -0
  71. package/dist/opencode/client.js +13 -0
  72. package/dist/opencode/events.js +140 -0
  73. package/dist/permission/manager.js +100 -0
  74. package/dist/permission/types.js +1 -0
  75. package/dist/pinned/format.js +29 -0
  76. package/dist/pinned/manager.js +682 -0
  77. package/dist/pinned/types.js +1 -0
  78. package/dist/process/manager.js +273 -0
  79. package/dist/process/types.js +1 -0
  80. package/dist/project/manager.js +88 -0
  81. package/dist/question/manager.js +176 -0
  82. package/dist/question/types.js +1 -0
  83. package/dist/rename/manager.js +53 -0
  84. package/dist/runtime/bootstrap.js +350 -0
  85. package/dist/runtime/mode.js +74 -0
  86. package/dist/runtime/paths.js +37 -0
  87. package/dist/scheduled-task/creation-manager.js +113 -0
  88. package/dist/scheduled-task/display.js +239 -0
  89. package/dist/scheduled-task/executor.js +87 -0
  90. package/dist/scheduled-task/foreground-state.js +32 -0
  91. package/dist/scheduled-task/next-run.js +207 -0
  92. package/dist/scheduled-task/runtime.js +368 -0
  93. package/dist/scheduled-task/schedule-parser.js +169 -0
  94. package/dist/scheduled-task/store.js +65 -0
  95. package/dist/scheduled-task/types.js +19 -0
  96. package/dist/session/cache-manager.js +455 -0
  97. package/dist/session/manager.js +10 -0
  98. package/dist/settings/manager.js +158 -0
  99. package/dist/stt/client.js +97 -0
  100. package/dist/summary/aggregator.js +1136 -0
  101. package/dist/summary/formatter.js +491 -0
  102. package/dist/summary/subagent-formatter.js +63 -0
  103. package/dist/summary/tool-message-batcher.js +90 -0
  104. package/dist/tts/client.js +130 -0
  105. package/dist/utils/error-format.js +29 -0
  106. package/dist/utils/logger.js +127 -0
  107. package/dist/utils/safe-background-task.js +33 -0
  108. package/dist/utils/telegram-rate-limit-retry.js +93 -0
  109. package/dist/variant/manager.js +103 -0
  110. package/dist/variant/types.js +1 -0
  111. package/package.json +79 -0
@@ -0,0 +1,145 @@
1
+ import { isTtsConfigured, getAvailableVoices } from "../../tts/client.js";
2
+ import { config } from "../../config.js";
3
+ import { logger } from "../../utils/logger.js";
4
+ import { t } from "../../i18n/index.js";
5
+ import { setCurrentTtsVoice, clearTtsVoice, setTtsEnabled, getCurrentTtsVoice } from "../../settings/manager.js";
6
+ const VOICE_MENU_PAGE_SIZE = 8;
7
+ function parseCallbackData(data) {
8
+ try {
9
+ const parsed = JSON.parse(data);
10
+ if (parsed.action === "select" && parsed.voice) {
11
+ return { action: "select", voice: parsed.voice };
12
+ }
13
+ if (parsed.action === "off") {
14
+ return { action: "off" };
15
+ }
16
+ if (parsed.action === "page" && typeof parsed.page === "number") {
17
+ return { action: "page", page: parsed.page };
18
+ }
19
+ return null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function buildVoiceKeyboard(voices, currentPage, currentVoice) {
26
+ const totalPages = Math.ceil(voices.length / VOICE_MENU_PAGE_SIZE);
27
+ const startIdx = currentPage * VOICE_MENU_PAGE_SIZE;
28
+ const pageVoices = voices.slice(startIdx, startIdx + VOICE_MENU_PAGE_SIZE);
29
+ const keyboard = [];
30
+ // Voice buttons
31
+ for (const voice of pageVoices) {
32
+ const isSelected = voice.id === currentVoice;
33
+ const text = isSelected ? `✅ ${voice.name}` : voice.name;
34
+ keyboard.push([{
35
+ text,
36
+ callback_data: JSON.stringify({ action: "select", voice: voice.id }),
37
+ }]);
38
+ }
39
+ // Navigation row
40
+ const navRow = [];
41
+ if (currentPage > 0) {
42
+ navRow.push({
43
+ text: t("tts.button.prev_page"),
44
+ callback_data: JSON.stringify({ action: "page", page: currentPage - 1 }),
45
+ });
46
+ }
47
+ if (currentPage < totalPages - 1) {
48
+ navRow.push({
49
+ text: t("tts.button.next_page"),
50
+ callback_data: JSON.stringify({ action: "page", page: currentPage + 1 }),
51
+ });
52
+ }
53
+ if (navRow.length > 0) {
54
+ keyboard.push(navRow);
55
+ }
56
+ // TTS Off button
57
+ keyboard.push([{
58
+ text: t("tts.button.off"),
59
+ callback_data: JSON.stringify({ action: "off" }),
60
+ }]);
61
+ return keyboard;
62
+ }
63
+ export async function voiceCommand(ctx) {
64
+ try {
65
+ // Check if TTS is configured
66
+ if (!isTtsConfigured()) {
67
+ await ctx.reply(t("tts.not_configured"));
68
+ return;
69
+ }
70
+ // Show loading message
71
+ const loadingMsg = await ctx.reply(t("tts.menu_loading"));
72
+ // Fetch available voices
73
+ const voices = await getAvailableVoices();
74
+ if (voices.length === 0) {
75
+ await ctx.api.editMessageText(ctx.chat.id, loadingMsg.message_id, t("tts.menu_empty"));
76
+ return;
77
+ }
78
+ const currentVoice = getCurrentTtsVoice() || config.tts.voice || voices[0]?.id;
79
+ // Edit loading message with voice selection menu
80
+ await ctx.api.editMessageText(ctx.chat.id, loadingMsg.message_id, t("tts.menu_current", { voice: currentVoice || "default" }), {
81
+ reply_markup: {
82
+ inline_keyboard: buildVoiceKeyboard(voices, 0, currentVoice),
83
+ },
84
+ });
85
+ }
86
+ catch (error) {
87
+ logger.error("[VoiceCommand] Error:", error);
88
+ await ctx.reply(t("tts.menu_error"));
89
+ }
90
+ }
91
+ export async function handleVoiceCallback(ctx) {
92
+ if (!ctx.callbackQuery || !ctx.callbackQuery.data) {
93
+ return false;
94
+ }
95
+ const callbackData = parseCallbackData(ctx.callbackQuery.data);
96
+ if (!callbackData) {
97
+ await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
98
+ return false;
99
+ }
100
+ try {
101
+ if (callbackData.action === "off") {
102
+ setTtsEnabled(false);
103
+ clearTtsVoice();
104
+ await ctx.answerCallbackQuery({ text: t("tts.off_success") });
105
+ await ctx.editMessageText(t("tts.off_success"));
106
+ return true;
107
+ }
108
+ if (callbackData.action === "select" && callbackData.voice) {
109
+ setCurrentTtsVoice(callbackData.voice);
110
+ setTtsEnabled(true);
111
+ await ctx.answerCallbackQuery({ text: t("tts.voice_changed", { voice: callbackData.voice }) });
112
+ // Update the menu to show selection
113
+ const voices = await getAvailableVoices();
114
+ const currentVoice = getCurrentTtsVoice();
115
+ await ctx.editMessageText(t("tts.menu_current", { voice: currentVoice || "default" }), {
116
+ reply_markup: {
117
+ inline_keyboard: buildVoiceKeyboard(voices, 0, currentVoice),
118
+ },
119
+ });
120
+ return true;
121
+ }
122
+ if (callbackData.action === "page" && typeof callbackData.page === "number") {
123
+ const voices = await getAvailableVoices();
124
+ const currentVoice = getCurrentTtsVoice();
125
+ await ctx.editMessageText(t("tts.menu_current", { voice: currentVoice || "default" }), {
126
+ reply_markup: {
127
+ inline_keyboard: buildVoiceKeyboard(voices, callbackData.page, currentVoice),
128
+ },
129
+ });
130
+ await ctx.answerCallbackQuery();
131
+ return true;
132
+ }
133
+ }
134
+ catch (error) {
135
+ logger.error("[VoiceCallback] Error:", error);
136
+ await ctx.answerCallbackQuery({ text: t("tts.voice_error", { error: String(error) }) });
137
+ return true;
138
+ }
139
+ return false;
140
+ }
141
+ // Add navigation button translations
142
+ // These need to be added to en.ts if not present
143
+ export function initTtsButtons() {
144
+ // This is just a marker - button translations are in en.ts
145
+ }
@@ -0,0 +1,118 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { selectAgent, getAvailableAgents, fetchCurrentAgent } from "../../agent/manager.js";
3
+ import { getAgentDisplayName, getAgentEmoji } from "../../agent/types.js";
4
+ import { getStoredModel } from "../../model/manager.js";
5
+ import { formatVariantForButton } from "../../variant/manager.js";
6
+ import { logger } from "../../utils/logger.js";
7
+ import { createMainKeyboard } from "../utils/keyboard.js";
8
+ import { pinnedMessageManager } from "../../pinned/manager.js";
9
+ import { keyboardManager } from "../../keyboard/manager.js";
10
+ import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
11
+ import { t } from "../../i18n/index.js";
12
+ /**
13
+ * Handle agent selection callback
14
+ * @param ctx grammY context
15
+ * @returns true if handled, false otherwise
16
+ */
17
+ export async function handleAgentSelect(ctx) {
18
+ const callbackQuery = ctx.callbackQuery;
19
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith("agent:")) {
20
+ return false;
21
+ }
22
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "agent");
23
+ if (!isActiveMenu) {
24
+ return true;
25
+ }
26
+ logger.debug(`[AgentHandler] Received callback: ${callbackQuery.data}`);
27
+ try {
28
+ if (ctx.chat) {
29
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
30
+ }
31
+ if (pinnedMessageManager.getContextLimit() === 0) {
32
+ await pinnedMessageManager.refreshContextLimit();
33
+ }
34
+ const agentName = callbackQuery.data.replace("agent:", "");
35
+ // Select agent and persist
36
+ selectAgent(agentName);
37
+ // Update keyboard manager state
38
+ keyboardManager.updateAgent(agentName);
39
+ // Update Reply Keyboard with new agent, current model, and context
40
+ const currentModel = getStoredModel();
41
+ const contextInfo = pinnedMessageManager.getContextInfo() ??
42
+ (pinnedMessageManager.getContextLimit() > 0
43
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
44
+ : null);
45
+ keyboardManager.updateModel(currentModel);
46
+ if (contextInfo) {
47
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
48
+ }
49
+ const state = keyboardManager.getState();
50
+ const variantName = state?.variantName ?? formatVariantForButton(currentModel.variant || "default");
51
+ const keyboard = createMainKeyboard(agentName, currentModel, contextInfo ?? undefined, variantName);
52
+ const displayName = getAgentDisplayName(agentName);
53
+ clearActiveInlineMenu("agent_selected");
54
+ // Send confirmation message with updated keyboard
55
+ await ctx.answerCallbackQuery({ text: t("agent.changed_callback", { name: displayName }) });
56
+ await ctx.reply(t("agent.changed_message", { name: displayName }), {
57
+ reply_markup: keyboard,
58
+ });
59
+ // Delete the inline menu message
60
+ await ctx.deleteMessage().catch(() => { });
61
+ return true;
62
+ }
63
+ catch (err) {
64
+ clearActiveInlineMenu("agent_select_error");
65
+ logger.error("[AgentHandler] Error handling agent select:", err);
66
+ await ctx.answerCallbackQuery({ text: t("agent.change_error_callback") }).catch(() => { });
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * Build inline keyboard with available agents
72
+ * @param currentAgent Current agent name for highlighting
73
+ * @returns InlineKeyboard with agent selection buttons
74
+ */
75
+ export async function buildAgentSelectionMenu(currentAgent) {
76
+ const keyboard = new InlineKeyboard();
77
+ const agents = await getAvailableAgents();
78
+ if (agents.length === 0) {
79
+ logger.warn("[AgentHandler] No available agents found");
80
+ return keyboard;
81
+ }
82
+ // Add button for each agent
83
+ agents.forEach((agent) => {
84
+ const emoji = getAgentEmoji(agent.name);
85
+ const isActive = agent.name === currentAgent;
86
+ const label = isActive
87
+ ? `✅ ${emoji} ${agent.name.toUpperCase()}`
88
+ : `${emoji} ${agent.name.charAt(0).toUpperCase() + agent.name.slice(1)}`;
89
+ keyboard.text(label, `agent:${agent.name}`).row();
90
+ });
91
+ return keyboard;
92
+ }
93
+ /**
94
+ * Show agent selection menu
95
+ * @param ctx grammY context
96
+ */
97
+ export async function showAgentSelectionMenu(ctx) {
98
+ try {
99
+ const currentAgent = await fetchCurrentAgent();
100
+ const keyboard = await buildAgentSelectionMenu(currentAgent);
101
+ if (keyboard.inline_keyboard.length === 0) {
102
+ await ctx.reply(t("agent.menu.empty"));
103
+ return;
104
+ }
105
+ const text = currentAgent
106
+ ? t("agent.menu.current", { name: getAgentDisplayName(currentAgent) })
107
+ : t("agent.menu.select");
108
+ await replyWithInlineMenu(ctx, {
109
+ menuKind: "agent",
110
+ text,
111
+ keyboard,
112
+ });
113
+ }
114
+ catch (err) {
115
+ logger.error("[AgentHandler] Error showing agent menu:", err);
116
+ await ctx.reply(t("agent.menu.error"));
117
+ }
118
+ }
@@ -0,0 +1,100 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { getCurrentSession } from "../../session/manager.js";
3
+ import { opencodeClient } from "../../opencode/client.js";
4
+ import { getStoredModel } from "../../model/manager.js";
5
+ import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
6
+ import { logger } from "../../utils/logger.js";
7
+ import { t } from "../../i18n/index.js";
8
+ /**
9
+ * Build inline keyboard with compact confirmation menu
10
+ * @returns InlineKeyboard with confirmation button
11
+ */
12
+ export function buildCompactConfirmationMenu() {
13
+ const keyboard = new InlineKeyboard();
14
+ keyboard.text(t("context.button.confirm"), "compact:confirm");
15
+ return keyboard;
16
+ }
17
+ /**
18
+ * Handle context button press (text message from Reply Keyboard)
19
+ * Shows inline menu with compact confirmation
20
+ * @param ctx grammY context
21
+ */
22
+ export async function handleContextButtonPress(ctx) {
23
+ logger.debug("[ContextHandler] Context button pressed");
24
+ const session = getCurrentSession();
25
+ if (!session) {
26
+ await ctx.reply(t("context.no_active_session"));
27
+ return;
28
+ }
29
+ const keyboard = buildCompactConfirmationMenu();
30
+ await replyWithInlineMenu(ctx, {
31
+ menuKind: "context",
32
+ text: t("context.confirm_text", { title: session.title }),
33
+ keyboard,
34
+ });
35
+ }
36
+ /**
37
+ * Handle compact confirmation callback
38
+ * Calls OpenCode API to compact the session
39
+ * @param ctx grammY context
40
+ */
41
+ export async function handleCompactConfirm(ctx) {
42
+ const callbackQuery = ctx.callbackQuery;
43
+ if (!callbackQuery?.data || callbackQuery.data !== "compact:confirm") {
44
+ return false;
45
+ }
46
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "context");
47
+ if (!isActiveMenu) {
48
+ return true;
49
+ }
50
+ logger.debug("[ContextHandler] Compact confirmed");
51
+ try {
52
+ const session = getCurrentSession();
53
+ if (!session) {
54
+ clearActiveInlineMenu("context_session_missing");
55
+ await ctx.answerCallbackQuery({ text: t("context.callback_session_not_found") });
56
+ await ctx.reply(t("context.no_active_session"));
57
+ await ctx.deleteMessage().catch(() => { });
58
+ return true;
59
+ }
60
+ // Answer callback query and delete menu immediately
61
+ await ctx.answerCallbackQuery({ text: t("context.callback_compacting") });
62
+ clearActiveInlineMenu("context_compact_confirmed");
63
+ await ctx.deleteMessage().catch(() => { });
64
+ // Send progress message
65
+ const progressMessage = await ctx.reply(t("context.progress"));
66
+ // Show typing indicator
67
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
68
+ const storedModel = getStoredModel();
69
+ logger.debug(`[ContextHandler] Calling summarize with sessionID=${session.id}, directory=${session.directory}, model=${storedModel.providerID}/${storedModel.modelID}`);
70
+ // Call summarize API (AI compaction)
71
+ const { error } = await opencodeClient.session.summarize({
72
+ sessionID: session.id,
73
+ directory: session.directory,
74
+ providerID: storedModel.providerID,
75
+ modelID: storedModel.modelID,
76
+ });
77
+ if (error) {
78
+ logger.error("[ContextHandler] Compact failed:", error);
79
+ // Update progress message to show error
80
+ await ctx.api
81
+ .editMessageText(ctx.chat.id, progressMessage.message_id, t("context.error"))
82
+ .catch(() => { });
83
+ return true;
84
+ }
85
+ logger.info(`[ContextHandler] Session compacted: ${session.id}`);
86
+ // Update progress message to show success
87
+ await ctx.api
88
+ .editMessageText(ctx.chat.id, progressMessage.message_id, t("context.success"))
89
+ .catch(() => { });
90
+ return true;
91
+ }
92
+ catch (err) {
93
+ clearActiveInlineMenu("context_compact_error");
94
+ logger.error("[ContextHandler] Compact exception:", err);
95
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
96
+ await ctx.reply(t("context.error"));
97
+ await ctx.deleteMessage().catch(() => { });
98
+ return false;
99
+ }
100
+ }
@@ -0,0 +1,65 @@
1
+ import { config } from "../../config.js";
2
+ import { processUserPrompt } from "./prompt.js";
3
+ import { downloadTelegramFile, toDataUri, isTextMimeType, isFileSizeAllowed, } from "../utils/file-download.js";
4
+ import { getModelCapabilities, supportsInput } from "../../model/capabilities.js";
5
+ import { getStoredModel } from "../../model/manager.js";
6
+ import { logger } from "../../utils/logger.js";
7
+ import { t } from "../../i18n/index.js";
8
+ export async function handleDocumentMessage(ctx, deps) {
9
+ const downloadFile = deps.downloadFile ?? downloadTelegramFile;
10
+ const getCapabilities = deps.getModelCapabilities ?? getModelCapabilities;
11
+ const getStored = deps.getStoredModel ?? getStoredModel;
12
+ const processPrompt = deps.processPrompt ?? processUserPrompt;
13
+ const doc = ctx.message?.document;
14
+ if (!doc) {
15
+ return;
16
+ }
17
+ const caption = ctx.message.caption || "";
18
+ const mimeType = doc.mime_type || "";
19
+ const filename = doc.file_name || "document";
20
+ try {
21
+ if (isTextMimeType(mimeType)) {
22
+ if (!isFileSizeAllowed(doc.file_size, config.files.maxFileSizeKb)) {
23
+ logger.warn(`[Document] Text file too large: ${filename} (${doc.file_size} bytes > ${config.files.maxFileSizeKb}KB)`);
24
+ await ctx.reply(t("bot.text_file_too_large", { maxSizeKb: String(config.files.maxFileSizeKb) }));
25
+ return;
26
+ }
27
+ await ctx.reply(t("bot.file_downloading"));
28
+ const downloadedFile = await downloadFile(ctx.api, doc.file_id);
29
+ const textContent = downloadedFile.buffer.toString("utf-8");
30
+ const promptWithFile = `--- Content of ${filename} ---\n${textContent}\n--- End of file ---\n\n${caption}`;
31
+ logger.info(`[Document] Sending text file (${downloadedFile.buffer.length} bytes, ${filename}) as prompt`);
32
+ await processPrompt(ctx, promptWithFile, deps);
33
+ return;
34
+ }
35
+ if (mimeType === "application/pdf") {
36
+ const storedModel = getStored();
37
+ const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID);
38
+ if (!supportsInput(capabilities, "pdf")) {
39
+ logger.warn(`[Document] Model ${storedModel.providerID}/${storedModel.modelID} doesn't support PDF input`);
40
+ await ctx.reply(t("bot.model_no_pdf"));
41
+ if (caption.trim().length > 0) {
42
+ await processPrompt(ctx, caption, deps);
43
+ }
44
+ return;
45
+ }
46
+ await ctx.reply(t("bot.file_downloading"));
47
+ const downloadedFile = await downloadFile(ctx.api, doc.file_id);
48
+ const dataUri = toDataUri(downloadedFile.buffer, mimeType);
49
+ const filePart = {
50
+ type: "file",
51
+ mime: mimeType,
52
+ filename: filename,
53
+ url: dataUri,
54
+ };
55
+ logger.info(`[Document] Sending PDF (${downloadedFile.buffer.length} bytes, ${filename}) with prompt`);
56
+ await processPrompt(ctx, caption, deps, [filePart]);
57
+ return;
58
+ }
59
+ logger.debug(`[Document] Unsupported document MIME type: ${mimeType}, ignoring`);
60
+ }
61
+ catch (err) {
62
+ logger.error("[Document] Error handling document message:", err);
63
+ await ctx.reply(t("bot.file_download_error"));
64
+ }
65
+ }
@@ -0,0 +1,119 @@
1
+ import { interactionManager } from "../../interaction/manager.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ import { t } from "../../i18n/index.js";
4
+ const INLINE_MENU_CANCEL_PREFIX = "inline:cancel:";
5
+ const LEGACY_CONTEXT_CANCEL_CALLBACK = "compact:cancel";
6
+ const INLINE_MENU_KINDS = ["project", "session", "model", "agent", "variant", "context"];
7
+ function isInlineMenuKind(value) {
8
+ return INLINE_MENU_KINDS.includes(value);
9
+ }
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 getActiveInlineMenuMetadata(state) {
19
+ if (!state || state.kind !== "inline") {
20
+ return null;
21
+ }
22
+ const menuKind = state.metadata.menuKind;
23
+ const messageId = state.metadata.messageId;
24
+ if (typeof menuKind !== "string" || !isInlineMenuKind(menuKind)) {
25
+ return null;
26
+ }
27
+ if (typeof messageId !== "number") {
28
+ return null;
29
+ }
30
+ return {
31
+ menuKind,
32
+ messageId,
33
+ };
34
+ }
35
+ function getInlineCancelCallbackData(menuKind) {
36
+ return `${INLINE_MENU_CANCEL_PREFIX}${menuKind}`;
37
+ }
38
+ export function appendInlineMenuCancelButton(keyboard, menuKind) {
39
+ while (keyboard.inline_keyboard.length > 0 &&
40
+ keyboard.inline_keyboard[keyboard.inline_keyboard.length - 1].length === 0) {
41
+ keyboard.inline_keyboard.pop();
42
+ }
43
+ if (keyboard.inline_keyboard.length > 0) {
44
+ keyboard.row();
45
+ }
46
+ keyboard.text(t("inline.button.cancel"), getInlineCancelCallbackData(menuKind));
47
+ return keyboard;
48
+ }
49
+ export async function replyWithInlineMenu(ctx, options) {
50
+ const keyboard = appendInlineMenuCancelButton(options.keyboard, options.menuKind);
51
+ const replyOptions = {
52
+ reply_markup: keyboard,
53
+ };
54
+ if (options.parseMode) {
55
+ replyOptions.parse_mode = options.parseMode;
56
+ }
57
+ const message = await ctx.reply(options.text, replyOptions);
58
+ interactionManager.start({
59
+ kind: "inline",
60
+ expectedInput: "callback",
61
+ metadata: {
62
+ menuKind: options.menuKind,
63
+ messageId: message.message_id,
64
+ },
65
+ });
66
+ logger.debug(`[InlineMenu] Opened menu: kind=${options.menuKind}, messageId=${message.message_id}`);
67
+ return message.message_id;
68
+ }
69
+ export async function ensureActiveInlineMenu(ctx, menuKind) {
70
+ const activeMetadata = getActiveInlineMenuMetadata(interactionManager.getSnapshot());
71
+ const callbackMessageId = getCallbackMessageId(ctx);
72
+ const isActive = !!activeMetadata &&
73
+ callbackMessageId !== null &&
74
+ activeMetadata.menuKind === menuKind &&
75
+ activeMetadata.messageId === callbackMessageId;
76
+ if (isActive) {
77
+ return true;
78
+ }
79
+ logger.debug(`[InlineMenu] Stale callback ignored: expectedKind=${menuKind}, activeKind=${activeMetadata?.menuKind || "none"}, callbackMessageId=${callbackMessageId || "none"}, activeMessageId=${activeMetadata?.messageId || "none"}`);
80
+ await ctx
81
+ .answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true })
82
+ .catch(() => { });
83
+ return false;
84
+ }
85
+ export function clearActiveInlineMenu(reason) {
86
+ const state = interactionManager.getSnapshot();
87
+ if (state?.kind === "inline") {
88
+ interactionManager.clear(reason);
89
+ }
90
+ }
91
+ export async function handleInlineMenuCancel(ctx) {
92
+ const data = ctx.callbackQuery?.data;
93
+ if (!data) {
94
+ return false;
95
+ }
96
+ let menuKind = null;
97
+ if (data === LEGACY_CONTEXT_CANCEL_CALLBACK) {
98
+ menuKind = "context";
99
+ }
100
+ else if (data.startsWith(INLINE_MENU_CANCEL_PREFIX)) {
101
+ const rawKind = data.slice(INLINE_MENU_CANCEL_PREFIX.length);
102
+ if (!isInlineMenuKind(rawKind)) {
103
+ return false;
104
+ }
105
+ menuKind = rawKind;
106
+ }
107
+ else {
108
+ return false;
109
+ }
110
+ const isActive = await ensureActiveInlineMenu(ctx, menuKind);
111
+ if (!isActive) {
112
+ return true;
113
+ }
114
+ clearActiveInlineMenu(`inline_menu_cancel:${menuKind}`);
115
+ await ctx.answerCallbackQuery({ text: t("inline.cancelled_callback") }).catch(() => { });
116
+ await ctx.deleteMessage().catch(() => { });
117
+ logger.debug(`[InlineMenu] Menu cancelled: kind=${menuKind}`);
118
+ return true;
119
+ }