opencode-telegram-group-topics-bot 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.env.example +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +305 -0
  4. package/dist/agent/manager.js +60 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +47 -0
  7. package/dist/bot/commands/abort.js +116 -0
  8. package/dist/bot/commands/commands.js +389 -0
  9. package/dist/bot/commands/constants.js +20 -0
  10. package/dist/bot/commands/definitions.js +25 -0
  11. package/dist/bot/commands/help.js +27 -0
  12. package/dist/bot/commands/models.js +38 -0
  13. package/dist/bot/commands/new.js +247 -0
  14. package/dist/bot/commands/opencode-start.js +85 -0
  15. package/dist/bot/commands/opencode-stop.js +44 -0
  16. package/dist/bot/commands/projects.js +304 -0
  17. package/dist/bot/commands/rename.js +173 -0
  18. package/dist/bot/commands/sessions.js +491 -0
  19. package/dist/bot/commands/start.js +67 -0
  20. package/dist/bot/commands/status.js +138 -0
  21. package/dist/bot/constants.js +49 -0
  22. package/dist/bot/handlers/agent.js +127 -0
  23. package/dist/bot/handlers/context.js +125 -0
  24. package/dist/bot/handlers/document.js +65 -0
  25. package/dist/bot/handlers/inline-menu.js +124 -0
  26. package/dist/bot/handlers/model.js +152 -0
  27. package/dist/bot/handlers/permission.js +281 -0
  28. package/dist/bot/handlers/prompt.js +263 -0
  29. package/dist/bot/handlers/question.js +285 -0
  30. package/dist/bot/handlers/variant.js +147 -0
  31. package/dist/bot/handlers/voice.js +173 -0
  32. package/dist/bot/index.js +945 -0
  33. package/dist/bot/message-patterns.js +4 -0
  34. package/dist/bot/middleware/auth.js +30 -0
  35. package/dist/bot/middleware/interaction-guard.js +80 -0
  36. package/dist/bot/middleware/unknown-command.js +22 -0
  37. package/dist/bot/scope.js +222 -0
  38. package/dist/bot/telegram-constants.js +3 -0
  39. package/dist/bot/telegram-rate-limiter.js +263 -0
  40. package/dist/bot/utils/commands.js +21 -0
  41. package/dist/bot/utils/file-download.js +91 -0
  42. package/dist/bot/utils/keyboard.js +85 -0
  43. package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
  44. package/dist/bot/utils/session-error-filter.js +34 -0
  45. package/dist/bot/utils/topic-link.js +29 -0
  46. package/dist/cli/args.js +98 -0
  47. package/dist/cli.js +80 -0
  48. package/dist/config.js +103 -0
  49. package/dist/i18n/de.js +330 -0
  50. package/dist/i18n/en.js +330 -0
  51. package/dist/i18n/es.js +330 -0
  52. package/dist/i18n/index.js +102 -0
  53. package/dist/i18n/ru.js +330 -0
  54. package/dist/i18n/zh.js +330 -0
  55. package/dist/index.js +28 -0
  56. package/dist/interaction/cleanup.js +24 -0
  57. package/dist/interaction/constants.js +25 -0
  58. package/dist/interaction/guard.js +100 -0
  59. package/dist/interaction/manager.js +113 -0
  60. package/dist/interaction/types.js +1 -0
  61. package/dist/keyboard/manager.js +115 -0
  62. package/dist/keyboard/types.js +1 -0
  63. package/dist/model/capabilities.js +62 -0
  64. package/dist/model/manager.js +257 -0
  65. package/dist/model/types.js +24 -0
  66. package/dist/opencode/client.js +13 -0
  67. package/dist/opencode/events.js +159 -0
  68. package/dist/opencode/prompt-submit-error.js +101 -0
  69. package/dist/permission/manager.js +92 -0
  70. package/dist/permission/types.js +1 -0
  71. package/dist/pinned/manager.js +405 -0
  72. package/dist/pinned/types.js +1 -0
  73. package/dist/process/manager.js +273 -0
  74. package/dist/process/types.js +1 -0
  75. package/dist/project/manager.js +88 -0
  76. package/dist/question/manager.js +186 -0
  77. package/dist/question/types.js +1 -0
  78. package/dist/rename/manager.js +64 -0
  79. package/dist/runtime/bootstrap.js +350 -0
  80. package/dist/runtime/mode.js +74 -0
  81. package/dist/runtime/paths.js +37 -0
  82. package/dist/runtime/process-error-handlers.js +24 -0
  83. package/dist/session/cache-manager.js +455 -0
  84. package/dist/session/manager.js +87 -0
  85. package/dist/settings/manager.js +283 -0
  86. package/dist/stt/client.js +64 -0
  87. package/dist/summary/aggregator.js +625 -0
  88. package/dist/summary/formatter.js +417 -0
  89. package/dist/summary/tool-message-batcher.js +277 -0
  90. package/dist/topic/colors.js +8 -0
  91. package/dist/topic/constants.js +10 -0
  92. package/dist/topic/manager.js +161 -0
  93. package/dist/topic/title-constants.js +2 -0
  94. package/dist/topic/title-format.js +10 -0
  95. package/dist/topic/title-sync.js +17 -0
  96. package/dist/utils/error-format.js +29 -0
  97. package/dist/utils/logger.js +175 -0
  98. package/dist/utils/safe-background-task.js +33 -0
  99. package/dist/variant/manager.js +103 -0
  100. package/dist/variant/types.js +1 -0
  101. package/package.json +76 -0
@@ -0,0 +1,67 @@
1
+ import { createDmKeyboard, createMainKeyboard } from "../utils/keyboard.js";
2
+ import { getStoredAgent } from "../../agent/manager.js";
3
+ import { getStoredModel } from "../../model/manager.js";
4
+ import { formatVariantForButton } from "../../variant/manager.js";
5
+ import { pinnedMessageManager } from "../../pinned/manager.js";
6
+ import { keyboardManager } from "../../keyboard/manager.js";
7
+ import { abortCurrentOperation } from "./abort.js";
8
+ import { clearSession } from "../../session/manager.js";
9
+ import { clearProject } from "../../settings/manager.js";
10
+ import { t } from "../../i18n/index.js";
11
+ import { SCOPE_CONTEXT, getScopeFromContext } from "../scope.js";
12
+ export async function startCommand(ctx) {
13
+ const scope = getScopeFromContext(ctx);
14
+ const scopeKey = scope?.key ?? "global";
15
+ const usePinned = ctx.chat?.type !== "private";
16
+ const isPrivateChat = ctx.chat?.type === "private";
17
+ const isTopicScope = scope?.context === SCOPE_CONTEXT.GROUP_TOPIC;
18
+ if (isPrivateChat) {
19
+ await abortCurrentOperation(ctx, { notifyUser: false });
20
+ await ctx.reply(`${t("start.welcome")}\n\n${t("start.welcome_dm")}`, {
21
+ reply_markup: createDmKeyboard(),
22
+ });
23
+ return;
24
+ }
25
+ if (ctx.chat) {
26
+ if (usePinned && !pinnedMessageManager.isInitialized(scopeKey)) {
27
+ pinnedMessageManager.initialize(ctx.api, ctx.chat.id, scopeKey, scope?.threadId ?? null);
28
+ }
29
+ keyboardManager.initialize(ctx.api, ctx.chat.id, scopeKey);
30
+ }
31
+ await abortCurrentOperation(ctx, { notifyUser: false });
32
+ if (!isTopicScope) {
33
+ clearSession(scopeKey);
34
+ clearProject(scopeKey);
35
+ keyboardManager.clearContext(scopeKey);
36
+ if (usePinned) {
37
+ await pinnedMessageManager.clear(scopeKey);
38
+ if (!pinnedMessageManager.isInitialized(scopeKey) && ctx.chat) {
39
+ pinnedMessageManager.initialize(ctx.api, ctx.chat.id, scopeKey, scope?.threadId ?? null);
40
+ }
41
+ }
42
+ }
43
+ if (usePinned && pinnedMessageManager.getContextLimit(scopeKey) === 0) {
44
+ await pinnedMessageManager.refreshContextLimit(scopeKey);
45
+ }
46
+ // Get current agent, model, and context
47
+ const currentAgent = getStoredAgent(scopeKey);
48
+ const currentModel = getStoredModel(scopeKey);
49
+ const variantName = formatVariantForButton(currentModel.variant || "default");
50
+ const contextInfo = keyboardManager.getContextInfo(scopeKey) ??
51
+ (usePinned ? pinnedMessageManager.getContextInfo(scopeKey) : null) ??
52
+ (usePinned && pinnedMessageManager.getContextLimit(scopeKey) > 0
53
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit(scopeKey) }
54
+ : null);
55
+ keyboardManager.updateAgent(currentAgent, scopeKey);
56
+ keyboardManager.updateModel(currentModel, scopeKey);
57
+ if (contextInfo) {
58
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit, scopeKey);
59
+ }
60
+ const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName, scope?.context === SCOPE_CONTEXT.GROUP_GENERAL
61
+ ? {
62
+ contextFirst: true,
63
+ contextLabel: t("keyboard.general_defaults"),
64
+ }
65
+ : undefined);
66
+ await ctx.reply(t("start.welcome"), { reply_markup: keyboard });
67
+ }
@@ -0,0 +1,138 @@
1
+ import { opencodeClient } from "../../opencode/client.js";
2
+ import { getCurrentSession } from "../../session/manager.js";
3
+ import { getCurrentProject } from "../../settings/manager.js";
4
+ import { fetchCurrentAgent } from "../../agent/manager.js";
5
+ import { getAgentDisplayName } from "../../agent/types.js";
6
+ import { fetchCurrentModel } from "../../model/manager.js";
7
+ import { formatModelForDisplay } from "../../model/types.js";
8
+ import { processManager } from "../../process/manager.js";
9
+ import { keyboardManager } from "../../keyboard/manager.js";
10
+ import { pinnedMessageManager } from "../../pinned/manager.js";
11
+ import { logger } from "../../utils/logger.js";
12
+ import { t } from "../../i18n/index.js";
13
+ import { sendMessageWithMarkdownFallback } from "../utils/send-with-markdown-fallback.js";
14
+ import { createDmKeyboard } from "../utils/keyboard.js";
15
+ import { getScopeFromContext, getScopeKeyFromContext, getThreadSendOptions } from "../scope.js";
16
+ export async function statusCommand(ctx) {
17
+ try {
18
+ const scopeKey = getScopeKeyFromContext(ctx);
19
+ const scope = getScopeFromContext(ctx);
20
+ const usePinned = ctx.chat?.type !== "private";
21
+ const isPrivateChat = ctx.chat?.type === "private";
22
+ const { data, error } = await opencodeClient.global.health();
23
+ if (error || !data) {
24
+ throw error || new Error("No data received from server");
25
+ }
26
+ if (isPrivateChat) {
27
+ const [projectsResult, sessionsResult] = await Promise.all([
28
+ opencodeClient.project.list(),
29
+ opencodeClient.session.list({ limit: 200 }),
30
+ ]);
31
+ const projectCount = projectsResult.data?.length ?? 0;
32
+ const sessionCount = sessionsResult.data?.length ?? 0;
33
+ const healthLabel = data.healthy ? t("status.health.healthy") : t("status.health.unhealthy");
34
+ let dmMessage = `📊 ${t("status.dm.title")}\n\n`;
35
+ dmMessage += `${t("status.line.health", { health: healthLabel })}\n`;
36
+ if (data.version) {
37
+ dmMessage += `${t("status.line.version", { version: data.version })}\n`;
38
+ }
39
+ if (processManager.isRunning()) {
40
+ const uptime = processManager.getUptime();
41
+ const uptimeStr = uptime ? Math.floor(uptime / 1000) : 0;
42
+ dmMessage += `${t("status.line.managed_yes")}\n`;
43
+ dmMessage += `${t("status.line.pid", { pid: processManager.getPID() ?? "-" })}\n`;
44
+ dmMessage += `${t("status.line.uptime_sec", { seconds: uptimeStr })}\n`;
45
+ }
46
+ else {
47
+ dmMessage += `${t("status.line.managed_no")}\n`;
48
+ }
49
+ dmMessage += `\n${t("status.global_overview")}\n`;
50
+ dmMessage += `${t("status.global_projects", { count: projectCount })}\n`;
51
+ dmMessage += `${t("status.global_sessions", { count: sessionCount })}\n\n`;
52
+ dmMessage += t("status.dm.hint");
53
+ await ctx.reply(dmMessage, { reply_markup: createDmKeyboard() });
54
+ return;
55
+ }
56
+ let message = `${t("status.header_running")}\n\n`;
57
+ const healthLabel = data.healthy ? t("status.health.healthy") : t("status.health.unhealthy");
58
+ message += `${t("status.line.health", { health: healthLabel })}\n`;
59
+ if (data.version) {
60
+ message += `${t("status.line.version", { version: data.version })}\n`;
61
+ }
62
+ // Add process management information
63
+ if (processManager.isRunning()) {
64
+ const uptime = processManager.getUptime();
65
+ const uptimeStr = uptime ? Math.floor(uptime / 1000) : 0;
66
+ message += `${t("status.line.managed_yes")}\n`;
67
+ message += `${t("status.line.pid", { pid: processManager.getPID() ?? "-" })}\n`;
68
+ message += `${t("status.line.uptime_sec", { seconds: uptimeStr })}\n`;
69
+ }
70
+ else {
71
+ message += `${t("status.line.managed_no")}\n`;
72
+ }
73
+ // Add agent mode information
74
+ const currentAgent = await fetchCurrentAgent(scopeKey);
75
+ const agentDisplay = currentAgent
76
+ ? getAgentDisplayName(currentAgent)
77
+ : t("status.agent_not_set");
78
+ message += `${t("status.line.mode", { mode: agentDisplay })}\n`;
79
+ // Add model information
80
+ const currentModel = fetchCurrentModel(scopeKey);
81
+ const modelDisplay = formatModelForDisplay(currentModel.providerID, currentModel.modelID);
82
+ message += `${t("status.line.model", { model: modelDisplay })}\n`;
83
+ const currentProject = getCurrentProject(scopeKey);
84
+ if (currentProject) {
85
+ const projectName = currentProject.name || currentProject.worktree;
86
+ message += `\n${t("status.project_selected", { project: projectName })}\n`;
87
+ }
88
+ else {
89
+ message += `\n${t("status.project_not_selected")}\n`;
90
+ message += t("status.project_hint");
91
+ }
92
+ const currentSession = getCurrentSession(scopeKey);
93
+ if (currentSession) {
94
+ message += `\n${t("status.session_selected", { title: currentSession.title })}\n`;
95
+ }
96
+ else {
97
+ message += `\n${t("status.session_not_selected")}\n`;
98
+ message += t("status.session_hint");
99
+ }
100
+ if (ctx.chat) {
101
+ if (usePinned && !pinnedMessageManager.isInitialized(scopeKey)) {
102
+ pinnedMessageManager.initialize(ctx.api, ctx.chat.id, scopeKey, scope?.threadId ?? null);
103
+ }
104
+ if (usePinned && pinnedMessageManager.getContextLimit(scopeKey) === 0) {
105
+ await pinnedMessageManager.refreshContextLimit(scopeKey);
106
+ }
107
+ keyboardManager.initialize(ctx.api, ctx.chat.id, scopeKey);
108
+ }
109
+ const contextInfo = (usePinned ? pinnedMessageManager.getContextInfo(scopeKey) : null) ??
110
+ keyboardManager.getContextInfo(scopeKey);
111
+ if (contextInfo) {
112
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit, scopeKey);
113
+ }
114
+ else if (usePinned && pinnedMessageManager.getContextLimit(scopeKey) > 0) {
115
+ keyboardManager.updateContext(0, pinnedMessageManager.getContextLimit(scopeKey), scopeKey);
116
+ }
117
+ const keyboard = keyboardManager.getKeyboard(scopeKey);
118
+ if (ctx.chat) {
119
+ await sendMessageWithMarkdownFallback({
120
+ api: ctx.api,
121
+ chatId: ctx.chat.id,
122
+ text: message,
123
+ options: {
124
+ reply_markup: keyboard,
125
+ ...getThreadSendOptions(scope?.threadId ?? null),
126
+ },
127
+ parseMode: "Markdown",
128
+ });
129
+ }
130
+ else {
131
+ await ctx.reply(message, { reply_markup: keyboard });
132
+ }
133
+ }
134
+ catch (error) {
135
+ logger.error("[Bot] Error checking server status:", error);
136
+ await ctx.reply(t("status.server_unavailable"));
137
+ }
138
+ }
@@ -0,0 +1,49 @@
1
+ export const CHAT_TYPE = {
2
+ PRIVATE: "private",
3
+ GROUP: "group",
4
+ SUPERGROUP: "supergroup",
5
+ CHANNEL: "channel",
6
+ };
7
+ export const TELEGRAM_CHAT_FIELD = {
8
+ IS_FORUM: "is_forum",
9
+ USERNAME: "username",
10
+ };
11
+ export const GENERAL_TOPIC = {
12
+ NAME: "🧭 Session Control",
13
+ };
14
+ export const TELEGRAM_ERROR_MARKER = {
15
+ NOT_ENOUGH_RIGHTS_CREATE_TOPIC: "not enough rights to create a topic",
16
+ };
17
+ export const TELEGRAM_URL = {
18
+ BASE: "https://t.me",
19
+ PRIVATE_SUPERGROUP_PATH: "/c",
20
+ };
21
+ export const TELEGRAM_CHAT_ID_PREFIX = {
22
+ PRIVATE_SUPERGROUP: "100",
23
+ };
24
+ export const BOT_I18N_KEY = {
25
+ GROUP_GENERAL_PROMPTS_DISABLED: "group.general.prompts_disabled",
26
+ GROUP_GENERAL_COMMANDS_ONLY: "group.general.commands_only",
27
+ TOPIC_UNBOUND: "topic.unbound",
28
+ TOPIC_CREATE_FROM_GENERAL: "topic.create_from_general",
29
+ CLEANUP_TOPIC_USE_GENERAL: "cleanup.topic_use_general",
30
+ CLEANUP_REQUIRES_FORUM_GENERAL: "cleanup.requires_forum_general",
31
+ CLEANUP_NO_TOPICS: "cleanup.no_topics",
32
+ CLEANUP_RESULT: "cleanup.result",
33
+ NEW_TOPIC_ONLY_IN_GENERAL: "new.topic_only_in_general",
34
+ NEW_REQUIRES_FORUM_GENERAL: "new.requires_forum_general",
35
+ NEW_TOPIC_CREATE_ERROR: "new.topic_create_error",
36
+ NEW_TOPIC_CREATE_NO_RIGHTS: "new.topic_create_no_rights",
37
+ NEW_TOPIC_CREATED: "new.topic_created",
38
+ NEW_GENERAL_CREATED: "new.general_created",
39
+ NEW_GENERAL_OPEN_LINK: "new.general_open_link",
40
+ SESSIONS_TOPIC_LOCKED: "sessions.topic_locked",
41
+ SESSIONS_GENERAL_OVERVIEW: "sessions.general_overview",
42
+ SESSIONS_GENERAL_ITEM: "sessions.general_item",
43
+ SESSIONS_GENERAL_EMPTY: "sessions.general_empty",
44
+ SESSIONS_BOUND_TOPIC_LINK: "sessions.bound_topic_link",
45
+ SESSIONS_CREATED_TOPIC_LINK: "sessions.created_topic_link",
46
+ PROJECTS_LOCKED_TOPIC_SCOPE: "projects.locked.topic_scope",
47
+ PROJECTS_LOCKED_GROUP_PROJECT: "projects.locked.group_project",
48
+ PROJECTS_LOCKED_CALLBACK: "projects.locked.callback",
49
+ };
@@ -0,0 +1,127 @@
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
+ import { SCOPE_CONTEXT, getScopeFromKey, getScopeKeyFromContext } from "../scope.js";
13
+ /**
14
+ * Handle agent selection callback
15
+ * @param ctx grammY context
16
+ * @returns true if handled, false otherwise
17
+ */
18
+ export async function handleAgentSelect(ctx) {
19
+ const callbackQuery = ctx.callbackQuery;
20
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith("agent:")) {
21
+ return false;
22
+ }
23
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "agent");
24
+ if (!isActiveMenu) {
25
+ return true;
26
+ }
27
+ logger.debug(`[AgentHandler] Received callback: ${callbackQuery.data}`);
28
+ try {
29
+ const scopeKey = getScopeKeyFromContext(ctx);
30
+ if (ctx.chat) {
31
+ keyboardManager.initialize(ctx.api, ctx.chat.id, scopeKey);
32
+ }
33
+ if (pinnedMessageManager.getContextLimit(scopeKey) === 0) {
34
+ await pinnedMessageManager.refreshContextLimit(scopeKey);
35
+ }
36
+ const agentName = callbackQuery.data.replace("agent:", "");
37
+ // Select agent and persist
38
+ selectAgent(agentName, scopeKey);
39
+ // Update keyboard manager state
40
+ keyboardManager.updateAgent(agentName, scopeKey);
41
+ // Update Reply Keyboard with new agent, current model, and context
42
+ const currentModel = getStoredModel(scopeKey);
43
+ const contextInfo = pinnedMessageManager.getContextInfo(scopeKey) ??
44
+ (pinnedMessageManager.getContextLimit(scopeKey) > 0
45
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit(scopeKey) }
46
+ : keyboardManager.getContextInfo(scopeKey));
47
+ keyboardManager.updateModel(currentModel, scopeKey);
48
+ if (contextInfo) {
49
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit, scopeKey);
50
+ }
51
+ const state = keyboardManager.getState(scopeKey);
52
+ const variantName = state?.variantName ?? formatVariantForButton(currentModel.variant || "default");
53
+ const scope = getScopeFromKey(scopeKey);
54
+ const keyboard = createMainKeyboard(agentName, currentModel, contextInfo ?? undefined, variantName, scope?.context === SCOPE_CONTEXT.GROUP_GENERAL
55
+ ? {
56
+ contextFirst: true,
57
+ contextLabel: t("keyboard.general_defaults"),
58
+ }
59
+ : undefined);
60
+ const displayName = getAgentDisplayName(agentName);
61
+ clearActiveInlineMenu("agent_selected", scopeKey);
62
+ // Send confirmation message with updated keyboard
63
+ await ctx.answerCallbackQuery({ text: t("agent.changed_callback", { name: displayName }) });
64
+ await ctx.reply(t("agent.changed_message", { name: displayName }), {
65
+ reply_markup: keyboard,
66
+ });
67
+ // Delete the inline menu message
68
+ await ctx.deleteMessage().catch(() => { });
69
+ return true;
70
+ }
71
+ catch (err) {
72
+ clearActiveInlineMenu("agent_select_error", getScopeKeyFromContext(ctx));
73
+ logger.error("[AgentHandler] Error handling agent select:", err);
74
+ await ctx.answerCallbackQuery({ text: t("agent.change_error_callback") }).catch(() => { });
75
+ return false;
76
+ }
77
+ }
78
+ /**
79
+ * Build inline keyboard with available agents
80
+ * @param currentAgent Current agent name for highlighting
81
+ * @returns InlineKeyboard with agent selection buttons
82
+ */
83
+ export async function buildAgentSelectionMenu(scopeKey, currentAgent) {
84
+ const keyboard = new InlineKeyboard();
85
+ const agents = await getAvailableAgents(scopeKey);
86
+ if (agents.length === 0) {
87
+ logger.warn("[AgentHandler] No available agents found");
88
+ return keyboard;
89
+ }
90
+ // Add button for each agent
91
+ agents.forEach((agent) => {
92
+ const emoji = getAgentEmoji(agent.name);
93
+ const isActive = agent.name === currentAgent;
94
+ const label = isActive
95
+ ? `✅ ${emoji} ${agent.name.toUpperCase()}`
96
+ : `${emoji} ${agent.name.charAt(0).toUpperCase() + agent.name.slice(1)}`;
97
+ keyboard.text(label, `agent:${agent.name}`).row();
98
+ });
99
+ return keyboard;
100
+ }
101
+ /**
102
+ * Show agent selection menu
103
+ * @param ctx grammY context
104
+ */
105
+ export async function showAgentSelectionMenu(ctx) {
106
+ try {
107
+ const scopeKey = getScopeKeyFromContext(ctx);
108
+ const currentAgent = await fetchCurrentAgent(scopeKey);
109
+ const keyboard = await buildAgentSelectionMenu(scopeKey, currentAgent);
110
+ if (keyboard.inline_keyboard.length === 0) {
111
+ await ctx.reply(t("agent.menu.empty"));
112
+ return;
113
+ }
114
+ const text = currentAgent
115
+ ? t("agent.menu.current", { name: getAgentDisplayName(currentAgent) })
116
+ : t("agent.menu.select");
117
+ await replyWithInlineMenu(ctx, {
118
+ menuKind: "agent",
119
+ text,
120
+ keyboard,
121
+ });
122
+ }
123
+ catch (err) {
124
+ logger.error("[AgentHandler] Error showing agent menu:", err);
125
+ await ctx.reply(t("agent.menu.error"));
126
+ }
127
+ }
@@ -0,0 +1,125 @@
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
+ import { GENERAL_TOPIC_THREAD_ID, SCOPE_CONTEXT, getChatActionThreadOptions, getScopeFromContext, getScopeKeyFromContext, getThreadSendOptions, } from "../scope.js";
9
+ import { TELEGRAM_CHAT_ACTION } from "../telegram-constants.js";
10
+ import { CHAT_TYPE, TELEGRAM_CHAT_FIELD } from "../constants.js";
11
+ function isGeneralForumScope(ctx) {
12
+ const scope = getScopeFromContext(ctx);
13
+ const isForumEnabled = ctx.chat?.type === CHAT_TYPE.SUPERGROUP &&
14
+ Reflect.get(ctx.chat, TELEGRAM_CHAT_FIELD.IS_FORUM) === true;
15
+ return Boolean(isForumEnabled &&
16
+ scope?.context === SCOPE_CONTEXT.GROUP_GENERAL &&
17
+ (scope.threadId === null || scope.threadId === GENERAL_TOPIC_THREAD_ID));
18
+ }
19
+ /**
20
+ * Build inline keyboard with compact confirmation menu
21
+ * @returns InlineKeyboard with confirmation button
22
+ */
23
+ export function buildCompactConfirmationMenu() {
24
+ const keyboard = new InlineKeyboard();
25
+ keyboard.text(t("context.button.confirm"), "compact:confirm");
26
+ return keyboard;
27
+ }
28
+ /**
29
+ * Handle context button press (text message from Reply Keyboard)
30
+ * Shows inline menu with compact confirmation
31
+ * @param ctx grammY context
32
+ */
33
+ export async function handleContextButtonPress(ctx) {
34
+ logger.debug("[ContextHandler] Context button pressed");
35
+ const scope = getScopeFromContext(ctx);
36
+ const scopeKey = getScopeKeyFromContext(ctx);
37
+ if (isGeneralForumScope(ctx)) {
38
+ await ctx.reply(t("context.general_not_available"), getThreadSendOptions(scope?.threadId ?? null));
39
+ return;
40
+ }
41
+ const session = getCurrentSession(scopeKey);
42
+ if (!session) {
43
+ await ctx.reply(t("context.no_active_session"), getThreadSendOptions(scope?.threadId ?? null));
44
+ return;
45
+ }
46
+ const keyboard = buildCompactConfirmationMenu();
47
+ await replyWithInlineMenu(ctx, {
48
+ menuKind: "context",
49
+ text: t("context.confirm_text", { title: session.title }),
50
+ keyboard,
51
+ });
52
+ }
53
+ /**
54
+ * Handle compact confirmation callback
55
+ * Calls OpenCode API to compact the session
56
+ * @param ctx grammY context
57
+ */
58
+ export async function handleCompactConfirm(ctx) {
59
+ const callbackQuery = ctx.callbackQuery;
60
+ if (!callbackQuery?.data || callbackQuery.data !== "compact:confirm") {
61
+ return false;
62
+ }
63
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "context");
64
+ if (!isActiveMenu) {
65
+ return true;
66
+ }
67
+ logger.debug("[ContextHandler] Compact confirmed");
68
+ try {
69
+ const scope = getScopeFromContext(ctx);
70
+ const scopeKey = getScopeKeyFromContext(ctx);
71
+ if (isGeneralForumScope(ctx)) {
72
+ clearActiveInlineMenu("context_general_scope", scopeKey);
73
+ await ctx.answerCallbackQuery({ text: t("context.general_not_available_callback") });
74
+ await ctx.deleteMessage().catch(() => { });
75
+ return true;
76
+ }
77
+ const session = getCurrentSession(scopeKey);
78
+ if (!session) {
79
+ clearActiveInlineMenu("context_session_missing", scopeKey);
80
+ await ctx.answerCallbackQuery({ text: t("context.callback_session_not_found") });
81
+ await ctx.reply(t("context.no_active_session"), getThreadSendOptions(scope?.threadId ?? null));
82
+ await ctx.deleteMessage().catch(() => { });
83
+ return true;
84
+ }
85
+ // Answer callback query and delete menu immediately
86
+ await ctx.answerCallbackQuery({ text: t("context.callback_compacting") });
87
+ clearActiveInlineMenu("context_compact_confirmed", scopeKey);
88
+ await ctx.deleteMessage().catch(() => { });
89
+ // Send progress message
90
+ const progressMessage = await ctx.reply(t("context.progress"), getThreadSendOptions(scope?.threadId ?? null));
91
+ // Show typing indicator
92
+ await ctx.api.sendChatAction(ctx.chat.id, TELEGRAM_CHAT_ACTION.TYPING, getChatActionThreadOptions(scope?.threadId ?? null));
93
+ const storedModel = getStoredModel(scopeKey);
94
+ logger.debug(`[ContextHandler] Calling summarize with sessionID=${session.id}, directory=${session.directory}, model=${storedModel.providerID}/${storedModel.modelID}`);
95
+ // Call summarize API (AI compaction)
96
+ const { error } = await opencodeClient.session.summarize({
97
+ sessionID: session.id,
98
+ directory: session.directory,
99
+ providerID: storedModel.providerID,
100
+ modelID: storedModel.modelID,
101
+ });
102
+ if (error) {
103
+ logger.error("[ContextHandler] Compact failed:", error);
104
+ // Update progress message to show error
105
+ await ctx.api
106
+ .editMessageText(ctx.chat.id, progressMessage.message_id, t("context.error"))
107
+ .catch(() => { });
108
+ return true;
109
+ }
110
+ logger.info(`[ContextHandler] Session compacted: ${session.id}`);
111
+ // Update progress message to show success
112
+ await ctx.api
113
+ .editMessageText(ctx.chat.id, progressMessage.message_id, t("context.success"))
114
+ .catch(() => { });
115
+ return true;
116
+ }
117
+ catch (err) {
118
+ clearActiveInlineMenu("context_compact_error", getScopeKeyFromContext(ctx));
119
+ logger.error("[ContextHandler] Compact exception:", err);
120
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
121
+ await ctx.reply(t("context.error"), getThreadSendOptions(getScopeFromContext(ctx)?.threadId ?? null));
122
+ await ctx.deleteMessage().catch(() => { });
123
+ return false;
124
+ }
125
+ }
@@ -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
+ }