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.
- package/.env.example +116 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/agent/manager.js +88 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +49 -0
- package/dist/bot/commands/abort.js +121 -0
- package/dist/bot/commands/commands.js +480 -0
- package/dist/bot/commands/definitions.js +27 -0
- package/dist/bot/commands/help.js +10 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +70 -0
- package/dist/bot/commands/opencode-start.js +101 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +223 -0
- package/dist/bot/commands/rename.js +139 -0
- package/dist/bot/commands/sessions.js +351 -0
- package/dist/bot/commands/start.js +43 -0
- package/dist/bot/commands/status.js +95 -0
- package/dist/bot/commands/task.js +399 -0
- package/dist/bot/commands/tasklist.js +220 -0
- package/dist/bot/commands/voice.js +145 -0
- package/dist/bot/handlers/agent.js +118 -0
- package/dist/bot/handlers/context.js +100 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +119 -0
- package/dist/bot/handlers/model.js +143 -0
- package/dist/bot/handlers/permission.js +235 -0
- package/dist/bot/handlers/prompt.js +240 -0
- package/dist/bot/handlers/question.js +390 -0
- package/dist/bot/handlers/tts.js +89 -0
- package/dist/bot/handlers/variant.js +138 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +977 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +95 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/streaming/response-streamer.js +286 -0
- package/dist/bot/streaming/tool-call-streamer.js +285 -0
- package/dist/bot/utils/busy-guard.js +15 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/finalize-assistant-response.js +52 -0
- package/dist/bot/utils/keyboard.js +69 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
- package/dist/bot/utils/telegram-text.js +28 -0
- package/dist/bot/utils/thinking-message.js +8 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +97 -0
- package/dist/i18n/de.js +357 -0
- package/dist/i18n/en.js +357 -0
- package/dist/i18n/es.js +357 -0
- package/dist/i18n/fr.js +357 -0
- package/dist/i18n/index.js +109 -0
- package/dist/i18n/ru.js +357 -0
- package/dist/i18n/zh.js +357 -0
- package/dist/index.js +26 -0
- package/dist/interaction/busy.js +8 -0
- package/dist/interaction/cleanup.js +32 -0
- package/dist/interaction/guard.js +140 -0
- package/dist/interaction/manager.js +106 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +172 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/context-limit.js +57 -0
- package/dist/model/manager.js +259 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +140 -0
- package/dist/permission/manager.js +100 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/format.js +29 -0
- package/dist/pinned/manager.js +682 -0
- package/dist/pinned/types.js +1 -0
- package/dist/process/manager.js +273 -0
- package/dist/process/types.js +1 -0
- package/dist/project/manager.js +88 -0
- package/dist/question/manager.js +176 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +53 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/scheduled-task/creation-manager.js +113 -0
- package/dist/scheduled-task/display.js +239 -0
- package/dist/scheduled-task/executor.js +87 -0
- package/dist/scheduled-task/foreground-state.js +32 -0
- package/dist/scheduled-task/next-run.js +207 -0
- package/dist/scheduled-task/runtime.js +368 -0
- package/dist/scheduled-task/schedule-parser.js +169 -0
- package/dist/scheduled-task/store.js +65 -0
- package/dist/scheduled-task/types.js +19 -0
- package/dist/session/cache-manager.js +455 -0
- package/dist/session/manager.js +10 -0
- package/dist/settings/manager.js +158 -0
- package/dist/stt/client.js +97 -0
- package/dist/summary/aggregator.js +1136 -0
- package/dist/summary/formatter.js +491 -0
- package/dist/summary/subagent-formatter.js +63 -0
- package/dist/summary/tool-message-batcher.js +90 -0
- package/dist/tts/client.js +130 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +127 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/utils/telegram-rate-limit-retry.js +93 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- 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
|
+
}
|