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.
- package/.env.example +74 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/dist/agent/manager.js +60 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +47 -0
- package/dist/bot/commands/abort.js +116 -0
- package/dist/bot/commands/commands.js +389 -0
- package/dist/bot/commands/constants.js +20 -0
- package/dist/bot/commands/definitions.js +25 -0
- package/dist/bot/commands/help.js +27 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +247 -0
- package/dist/bot/commands/opencode-start.js +85 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +304 -0
- package/dist/bot/commands/rename.js +173 -0
- package/dist/bot/commands/sessions.js +491 -0
- package/dist/bot/commands/start.js +67 -0
- package/dist/bot/commands/status.js +138 -0
- package/dist/bot/constants.js +49 -0
- package/dist/bot/handlers/agent.js +127 -0
- package/dist/bot/handlers/context.js +125 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +124 -0
- package/dist/bot/handlers/model.js +152 -0
- package/dist/bot/handlers/permission.js +281 -0
- package/dist/bot/handlers/prompt.js +263 -0
- package/dist/bot/handlers/question.js +285 -0
- package/dist/bot/handlers/variant.js +147 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +945 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +80 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/scope.js +222 -0
- package/dist/bot/telegram-constants.js +3 -0
- package/dist/bot/telegram-rate-limiter.js +263 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/keyboard.js +85 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
- package/dist/bot/utils/session-error-filter.js +34 -0
- package/dist/bot/utils/topic-link.js +29 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +103 -0
- package/dist/i18n/de.js +330 -0
- package/dist/i18n/en.js +330 -0
- package/dist/i18n/es.js +330 -0
- package/dist/i18n/index.js +102 -0
- package/dist/i18n/ru.js +330 -0
- package/dist/i18n/zh.js +330 -0
- package/dist/index.js +28 -0
- package/dist/interaction/cleanup.js +24 -0
- package/dist/interaction/constants.js +25 -0
- package/dist/interaction/guard.js +100 -0
- package/dist/interaction/manager.js +113 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +115 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/manager.js +257 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +159 -0
- package/dist/opencode/prompt-submit-error.js +101 -0
- package/dist/permission/manager.js +92 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/manager.js +405 -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 +186 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +64 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/runtime/process-error-handlers.js +24 -0
- package/dist/session/cache-manager.js +455 -0
- package/dist/session/manager.js +87 -0
- package/dist/settings/manager.js +283 -0
- package/dist/stt/client.js +64 -0
- package/dist/summary/aggregator.js +625 -0
- package/dist/summary/formatter.js +417 -0
- package/dist/summary/tool-message-batcher.js +277 -0
- package/dist/topic/colors.js +8 -0
- package/dist/topic/constants.js +10 -0
- package/dist/topic/manager.js +161 -0
- package/dist/topic/title-constants.js +2 -0
- package/dist/topic/title-format.js +10 -0
- package/dist/topic/title-sync.js +17 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +175 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- 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
|
+
}
|