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,263 @@
|
|
|
1
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
2
|
+
import { classifyPromptSubmitError } from "../../opencode/prompt-submit-error.js";
|
|
3
|
+
import { clearSession, getCurrentSession, setCurrentSession } from "../../session/manager.js";
|
|
4
|
+
import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
|
|
5
|
+
import { getCurrentProject } from "../../settings/manager.js";
|
|
6
|
+
import { getStoredAgent } from "../../agent/manager.js";
|
|
7
|
+
import { getStoredModel } from "../../model/manager.js";
|
|
8
|
+
import { formatVariantForButton } from "../../variant/manager.js";
|
|
9
|
+
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
10
|
+
import { keyboardManager } from "../../keyboard/manager.js";
|
|
11
|
+
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
12
|
+
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
13
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
14
|
+
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
15
|
+
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
16
|
+
import { formatErrorDetails } from "../../utils/error-format.js";
|
|
17
|
+
import { logger } from "../../utils/logger.js";
|
|
18
|
+
import { t } from "../../i18n/index.js";
|
|
19
|
+
import { GLOBAL_SCOPE_KEY, SCOPE_CONTEXT, getScopeFromContext, getThreadSendOptions, } from "../scope.js";
|
|
20
|
+
import { BOT_I18N_KEY, CHAT_TYPE } from "../constants.js";
|
|
21
|
+
import { INTERACTION_CLEAR_REASON } from "../../interaction/constants.js";
|
|
22
|
+
import { getTopicBindingByScopeKey } from "../../topic/manager.js";
|
|
23
|
+
/** Module-level references for async callbacks that don't have ctx. */
|
|
24
|
+
let botInstance = null;
|
|
25
|
+
let chatIdInstance = null;
|
|
26
|
+
export function getPromptBotInstance() {
|
|
27
|
+
return botInstance;
|
|
28
|
+
}
|
|
29
|
+
export function getPromptChatId() {
|
|
30
|
+
return chatIdInstance;
|
|
31
|
+
}
|
|
32
|
+
async function isSessionBusy(sessionId, directory) {
|
|
33
|
+
try {
|
|
34
|
+
const { data, error } = await opencodeClient.session.status({ directory });
|
|
35
|
+
if (error || !data) {
|
|
36
|
+
logger.warn("[Bot] Failed to check session status before prompt:", error);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const sessionStatus = data[sessionId];
|
|
40
|
+
if (!sessionStatus) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
logger.debug(`[Bot] Current session status before prompt: ${sessionStatus.type || "unknown"}`);
|
|
44
|
+
return sessionStatus.type === "busy";
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
logger.warn("[Bot] Error checking session status before prompt:", err);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function resetMismatchedSessionContextForScope(scopeKey) {
|
|
52
|
+
clearAllInteractionState(INTERACTION_CLEAR_REASON.SESSION_MISMATCH_RESET, scopeKey);
|
|
53
|
+
clearSession(scopeKey);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Processes a user prompt: ensures project/session, subscribes to events, and sends
|
|
57
|
+
* the prompt to OpenCode. Used by text, voice, and photo message handlers.
|
|
58
|
+
*
|
|
59
|
+
* @param ctx - Grammy context
|
|
60
|
+
* @param text - Text content of the prompt
|
|
61
|
+
* @param deps - Dependencies (bot and event subscription)
|
|
62
|
+
* @param fileParts - Optional file parts (for photo/document attachments)
|
|
63
|
+
* @returns true if the prompt was dispatched, false if it was blocked/failed early.
|
|
64
|
+
*/
|
|
65
|
+
export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
66
|
+
const { bot, ensureEventSubscription } = deps;
|
|
67
|
+
const scope = getScopeFromContext(ctx);
|
|
68
|
+
const scopeKey = scope?.key ?? GLOBAL_SCOPE_KEY;
|
|
69
|
+
const usePinned = ctx.chat?.type !== CHAT_TYPE.PRIVATE;
|
|
70
|
+
const currentProject = getCurrentProject(scopeKey);
|
|
71
|
+
if (!currentProject) {
|
|
72
|
+
await ctx.reply(t("bot.project_not_selected"));
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
botInstance = bot;
|
|
76
|
+
chatIdInstance = ctx.chat.id;
|
|
77
|
+
// Initialize pinned message manager if not already
|
|
78
|
+
if (usePinned && !pinnedMessageManager.isInitialized(scopeKey)) {
|
|
79
|
+
pinnedMessageManager.initialize(bot.api, ctx.chat.id, scopeKey, scope?.threadId ?? null);
|
|
80
|
+
}
|
|
81
|
+
// Initialize keyboard manager if not already
|
|
82
|
+
keyboardManager.initialize(bot.api, ctx.chat.id, scopeKey);
|
|
83
|
+
let currentSession = getCurrentSession(scopeKey);
|
|
84
|
+
if (scope?.context === SCOPE_CONTEXT.GROUP_TOPIC && !getTopicBindingByScopeKey(scopeKey)) {
|
|
85
|
+
await ctx.reply(t(BOT_I18N_KEY.TOPIC_UNBOUND), getThreadSendOptions(scope.threadId));
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (currentSession && currentSession.directory !== currentProject.worktree) {
|
|
89
|
+
logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
|
|
90
|
+
resetMismatchedSessionContextForScope(scopeKey);
|
|
91
|
+
await ctx.reply(t("bot.session_reset_project_mismatch"));
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
if (!currentSession) {
|
|
95
|
+
if (scope?.context === SCOPE_CONTEXT.GROUP_TOPIC) {
|
|
96
|
+
await ctx.reply(t(BOT_I18N_KEY.TOPIC_UNBOUND), getThreadSendOptions(scope.threadId));
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
await ctx.reply(t("bot.creating_session"));
|
|
100
|
+
const { data: session, error } = await opencodeClient.session.create({
|
|
101
|
+
directory: currentProject.worktree,
|
|
102
|
+
});
|
|
103
|
+
if (error || !session) {
|
|
104
|
+
await ctx.reply(t("bot.create_session_error"));
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
logger.info(`[Bot] Created new session: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
|
|
108
|
+
currentSession = {
|
|
109
|
+
id: session.id,
|
|
110
|
+
title: session.title,
|
|
111
|
+
directory: currentProject.worktree,
|
|
112
|
+
};
|
|
113
|
+
setCurrentSession(currentSession, scopeKey);
|
|
114
|
+
await ingestSessionInfoForCache(session);
|
|
115
|
+
// Create pinned message for new session
|
|
116
|
+
if (usePinned) {
|
|
117
|
+
try {
|
|
118
|
+
await pinnedMessageManager.onSessionChange(session.id, session.title, scopeKey);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
logger.error("[Bot] Error creating pinned message for new session:", err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (usePinned && pinnedMessageManager.getContextLimit(scopeKey) === 0) {
|
|
125
|
+
await pinnedMessageManager.refreshContextLimit(scopeKey);
|
|
126
|
+
}
|
|
127
|
+
const currentAgent = getStoredAgent(scopeKey);
|
|
128
|
+
const currentModel = getStoredModel(scopeKey);
|
|
129
|
+
const contextInfo = (usePinned ? pinnedMessageManager.getContextInfo(scopeKey) : null) ??
|
|
130
|
+
keyboardManager.getContextInfo(scopeKey) ??
|
|
131
|
+
(usePinned && pinnedMessageManager.getContextLimit(scopeKey) > 0
|
|
132
|
+
? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit(scopeKey) }
|
|
133
|
+
: null);
|
|
134
|
+
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
135
|
+
const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
|
|
136
|
+
await ctx.reply(t("bot.session_created", { title: session.title }), {
|
|
137
|
+
reply_markup: keyboard,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
|
|
142
|
+
// Ensure pinned message exists for existing session
|
|
143
|
+
if (usePinned && !pinnedMessageManager.getState(scopeKey).messageId) {
|
|
144
|
+
try {
|
|
145
|
+
await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title, scopeKey);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
logger.error("[Bot] Error creating pinned message for existing session:", err);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
await ensureEventSubscription(currentSession.directory);
|
|
153
|
+
summaryAggregator.setSession(currentSession.id);
|
|
154
|
+
const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
|
|
155
|
+
if (sessionIsBusy) {
|
|
156
|
+
logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
|
|
157
|
+
await ctx.reply(t("bot.session_busy"));
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const currentAgent = getStoredAgent(scopeKey);
|
|
162
|
+
const storedModel = getStoredModel(scopeKey);
|
|
163
|
+
// Build parts array with text and files
|
|
164
|
+
const parts = [];
|
|
165
|
+
// Add text part if present
|
|
166
|
+
if (text.trim().length > 0) {
|
|
167
|
+
parts.push({ type: "text", text });
|
|
168
|
+
}
|
|
169
|
+
// Add file parts
|
|
170
|
+
parts.push(...fileParts);
|
|
171
|
+
// If no text and files exist, use a placeholder
|
|
172
|
+
if (parts.length === 0 || (parts.length > 0 && parts.every((p) => p.type === "file"))) {
|
|
173
|
+
if (fileParts.length > 0) {
|
|
174
|
+
// Files without text - add a minimal system prompt
|
|
175
|
+
parts.unshift({ type: "text", text: "See attached file" });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const promptOptions = {
|
|
179
|
+
sessionID: currentSession.id,
|
|
180
|
+
directory: currentSession.directory,
|
|
181
|
+
parts,
|
|
182
|
+
agent: currentAgent,
|
|
183
|
+
};
|
|
184
|
+
// Use stored model (from settings or config)
|
|
185
|
+
if (storedModel.providerID && storedModel.modelID) {
|
|
186
|
+
promptOptions.model = {
|
|
187
|
+
providerID: storedModel.providerID,
|
|
188
|
+
modelID: storedModel.modelID,
|
|
189
|
+
};
|
|
190
|
+
// Add variant if specified
|
|
191
|
+
if (storedModel.variant) {
|
|
192
|
+
promptOptions.variant = storedModel.variant;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const promptErrorLogContext = {
|
|
196
|
+
sessionId: currentSession.id,
|
|
197
|
+
directory: currentSession.directory,
|
|
198
|
+
agent: currentAgent || "default",
|
|
199
|
+
modelProvider: storedModel.providerID || "default",
|
|
200
|
+
modelId: storedModel.modelID || "default",
|
|
201
|
+
variant: storedModel.variant || "default",
|
|
202
|
+
promptLength: text.length,
|
|
203
|
+
fileCount: fileParts.length,
|
|
204
|
+
};
|
|
205
|
+
logger.info(`[Bot] Calling session.promptAsync (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
|
|
206
|
+
// CRITICAL: DO NOT wait for session.promptAsync to complete.
|
|
207
|
+
// If we wait, the handler will not finish and grammY will not call getUpdates,
|
|
208
|
+
// which blocks receiving button callback_query updates.
|
|
209
|
+
// The processing result will arrive via SSE events.
|
|
210
|
+
safeBackgroundTask({
|
|
211
|
+
taskName: "session.promptAsync",
|
|
212
|
+
task: () => opencodeClient.session.promptAsync(promptOptions),
|
|
213
|
+
onSuccess: ({ error }) => {
|
|
214
|
+
if (error) {
|
|
215
|
+
const details = formatErrorDetails(error, 6000);
|
|
216
|
+
const errorType = classifyPromptSubmitError(error);
|
|
217
|
+
logger.error("[Bot] OpenCode API returned an error for session.promptAsync", promptErrorLogContext);
|
|
218
|
+
logger.error("[Bot] session.promptAsync error details:", details);
|
|
219
|
+
logger.error("[Bot] session.promptAsync raw API error object:", error);
|
|
220
|
+
const errorMessageKey = errorType === "busy"
|
|
221
|
+
? "bot.session_busy"
|
|
222
|
+
: errorType === "session_not_found"
|
|
223
|
+
? "bot.prompt_send_error_session_not_found"
|
|
224
|
+
: "bot.prompt_send_error";
|
|
225
|
+
// Send user-friendly error via API directly because ctx is no longer available
|
|
226
|
+
void bot.api
|
|
227
|
+
.sendMessage(ctx.chat.id, t(errorMessageKey), {
|
|
228
|
+
...getThreadSendOptions(scope?.threadId ?? null),
|
|
229
|
+
})
|
|
230
|
+
.catch(() => { });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
logger.info("[Bot] session.promptAsync accepted");
|
|
234
|
+
},
|
|
235
|
+
onError: (error) => {
|
|
236
|
+
const details = formatErrorDetails(error, 6000);
|
|
237
|
+
const errorType = classifyPromptSubmitError(error);
|
|
238
|
+
logger.error("[Bot] session.promptAsync background task failed", promptErrorLogContext);
|
|
239
|
+
logger.error("[Bot] session.promptAsync background failure details:", details);
|
|
240
|
+
logger.error("[Bot] session.promptAsync raw background error object:", error);
|
|
241
|
+
const errorMessageKey = errorType === "busy"
|
|
242
|
+
? "bot.session_busy"
|
|
243
|
+
: errorType === "session_not_found"
|
|
244
|
+
? "bot.prompt_send_error_session_not_found"
|
|
245
|
+
: "bot.prompt_send_error";
|
|
246
|
+
void bot.api
|
|
247
|
+
.sendMessage(ctx.chat.id, t(errorMessageKey), {
|
|
248
|
+
...getThreadSendOptions(scope?.threadId ?? null),
|
|
249
|
+
})
|
|
250
|
+
.catch(() => { });
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
logger.error("Error in prompt handler:", err);
|
|
257
|
+
if (interactionManager.getSnapshot(scopeKey)) {
|
|
258
|
+
clearAllInteractionState(INTERACTION_CLEAR_REASON.MESSAGE_HANDLER_ERROR, scopeKey);
|
|
259
|
+
}
|
|
260
|
+
await ctx.reply(t("error.generic"));
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import { questionManager } from "../../question/manager.js";
|
|
3
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
4
|
+
import { getCurrentProject } from "../../settings/manager.js";
|
|
5
|
+
import { getCurrentSession } from "../../session/manager.js";
|
|
6
|
+
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
7
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
8
|
+
import { logger } from "../../utils/logger.js";
|
|
9
|
+
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
10
|
+
import { t } from "../../i18n/index.js";
|
|
11
|
+
import { getScopeKeyFromContext, getThreadSendOptions } from "../scope.js";
|
|
12
|
+
const MAX_BUTTON_LENGTH = 60;
|
|
13
|
+
function getCallbackMessageId(ctx) {
|
|
14
|
+
const message = ctx.callbackQuery?.message;
|
|
15
|
+
if (!message || !("message_id" in message)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const messageId = message.message_id;
|
|
19
|
+
return typeof messageId === "number" ? messageId : null;
|
|
20
|
+
}
|
|
21
|
+
function clearQuestionInteraction(reason, scopeKey) {
|
|
22
|
+
const state = interactionManager.getSnapshot(scopeKey);
|
|
23
|
+
if (state?.kind === "question") {
|
|
24
|
+
interactionManager.clear(reason, scopeKey);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function syncQuestionInteractionState(expectedInput, questionIndex, messageId, scopeKey) {
|
|
28
|
+
const metadata = {
|
|
29
|
+
questionIndex,
|
|
30
|
+
inputMode: expectedInput === "mixed" ? "custom" : "options",
|
|
31
|
+
};
|
|
32
|
+
const requestID = questionManager.getRequestID(scopeKey);
|
|
33
|
+
if (requestID) {
|
|
34
|
+
metadata.requestID = requestID;
|
|
35
|
+
}
|
|
36
|
+
if (messageId !== null) {
|
|
37
|
+
metadata.messageId = messageId;
|
|
38
|
+
}
|
|
39
|
+
const state = interactionManager.getSnapshot(scopeKey);
|
|
40
|
+
if (state?.kind === "question") {
|
|
41
|
+
interactionManager.transition({
|
|
42
|
+
expectedInput,
|
|
43
|
+
metadata,
|
|
44
|
+
}, scopeKey);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
interactionManager.start({
|
|
48
|
+
kind: "question",
|
|
49
|
+
expectedInput,
|
|
50
|
+
metadata,
|
|
51
|
+
}, scopeKey);
|
|
52
|
+
}
|
|
53
|
+
export async function handleQuestionCallback(ctx) {
|
|
54
|
+
const data = ctx.callbackQuery?.data;
|
|
55
|
+
if (!data || !data.startsWith("question:")) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
59
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
60
|
+
if (!questionManager.isActive(scopeKey) ||
|
|
61
|
+
!questionManager.isActiveMessage(callbackMessageId, scopeKey)) {
|
|
62
|
+
clearQuestionInteraction("question_inactive_callback", scopeKey);
|
|
63
|
+
await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const parts = data.split(":");
|
|
67
|
+
const action = parts[1];
|
|
68
|
+
const questionIndex = Number.parseInt(parts[2], 10);
|
|
69
|
+
if (Number.isNaN(questionIndex) || questionIndex !== questionManager.getCurrentIndex(scopeKey)) {
|
|
70
|
+
await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (action === "cancel") {
|
|
74
|
+
questionManager.cancel(scopeKey);
|
|
75
|
+
clearQuestionInteraction("question_cancelled", scopeKey);
|
|
76
|
+
await ctx.editMessageText(t("question.cancelled")).catch(() => { });
|
|
77
|
+
await ctx.answerCallbackQuery();
|
|
78
|
+
questionManager.clear(scopeKey);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (action === "custom") {
|
|
82
|
+
questionManager.startCustomInput(questionIndex, scopeKey);
|
|
83
|
+
syncQuestionInteractionState("mixed", questionIndex, questionManager.getActiveMessageId(scopeKey), scopeKey);
|
|
84
|
+
await ctx.answerCallbackQuery({ text: t("question.enter_custom_callback"), show_alert: true });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (action === "select") {
|
|
88
|
+
const optionIndex = Number.parseInt(parts[3], 10);
|
|
89
|
+
if (Number.isNaN(optionIndex)) {
|
|
90
|
+
await ctx.answerCallbackQuery({
|
|
91
|
+
text: t("question.processing_error_callback"),
|
|
92
|
+
show_alert: true,
|
|
93
|
+
});
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
questionManager.selectOption(questionIndex, optionIndex, scopeKey);
|
|
97
|
+
const question = questionManager.getCurrentQuestion(scopeKey);
|
|
98
|
+
if (!question) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (question.multiple) {
|
|
102
|
+
await updateQuestionMessage(ctx, scopeKey);
|
|
103
|
+
await ctx.answerCallbackQuery();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
await ctx.answerCallbackQuery();
|
|
107
|
+
await ctx.deleteMessage().catch(() => { });
|
|
108
|
+
await showNextQuestion(ctx, scopeKey);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (action === "submit") {
|
|
112
|
+
const answer = questionManager.getSelectedAnswer(questionIndex, scopeKey);
|
|
113
|
+
if (!answer) {
|
|
114
|
+
await ctx.answerCallbackQuery({
|
|
115
|
+
text: t("question.select_one_required_callback"),
|
|
116
|
+
show_alert: true,
|
|
117
|
+
});
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
await ctx.answerCallbackQuery();
|
|
121
|
+
await ctx.deleteMessage().catch(() => { });
|
|
122
|
+
await showNextQuestion(ctx, scopeKey);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
await ctx.answerCallbackQuery({
|
|
126
|
+
text: t("question.processing_error_callback"),
|
|
127
|
+
show_alert: true,
|
|
128
|
+
});
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
async function updateQuestionMessage(ctx, scopeKey) {
|
|
132
|
+
const question = questionManager.getCurrentQuestion(scopeKey);
|
|
133
|
+
if (!question) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await ctx
|
|
137
|
+
.editMessageText(formatQuestionText(question, scopeKey), {
|
|
138
|
+
reply_markup: buildQuestionKeyboard(question, questionManager.getSelectedOptions(questionManager.getCurrentIndex(scopeKey), scopeKey), scopeKey),
|
|
139
|
+
parse_mode: "Markdown",
|
|
140
|
+
})
|
|
141
|
+
.catch(() => { });
|
|
142
|
+
}
|
|
143
|
+
export async function showCurrentQuestion(bot, chatId, scopeKey, threadId) {
|
|
144
|
+
const question = questionManager.getCurrentQuestion(scopeKey);
|
|
145
|
+
if (!question) {
|
|
146
|
+
await showPollSummary(bot, chatId, scopeKey, threadId);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const message = await bot.sendMessage(chatId, formatQuestionText(question, scopeKey), {
|
|
150
|
+
reply_markup: buildQuestionKeyboard(question, questionManager.getSelectedOptions(questionManager.getCurrentIndex(scopeKey), scopeKey), scopeKey),
|
|
151
|
+
parse_mode: "Markdown",
|
|
152
|
+
...getThreadSendOptions(threadId),
|
|
153
|
+
});
|
|
154
|
+
questionManager.addMessageId(message.message_id, scopeKey);
|
|
155
|
+
questionManager.setActiveMessageId(message.message_id, scopeKey);
|
|
156
|
+
syncQuestionInteractionState("callback", questionManager.getCurrentIndex(scopeKey), questionManager.getActiveMessageId(scopeKey), scopeKey);
|
|
157
|
+
summaryAggregator.stopTypingIndicator();
|
|
158
|
+
}
|
|
159
|
+
export async function handleQuestionTextAnswer(ctx) {
|
|
160
|
+
const text = ctx.message?.text;
|
|
161
|
+
if (!text)
|
|
162
|
+
return;
|
|
163
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
164
|
+
const currentIndex = questionManager.getCurrentIndex(scopeKey);
|
|
165
|
+
if (!questionManager.isWaitingForCustomInput(currentIndex, scopeKey)) {
|
|
166
|
+
await ctx.reply(t("question.use_custom_button_first"));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
questionManager.setCustomAnswer(currentIndex, text, scopeKey);
|
|
170
|
+
questionManager.clearCustomInput(scopeKey);
|
|
171
|
+
const activeMessageId = questionManager.getActiveMessageId(scopeKey);
|
|
172
|
+
if (activeMessageId !== null && ctx.chat) {
|
|
173
|
+
await ctx.api.deleteMessage(ctx.chat.id, activeMessageId).catch(() => { });
|
|
174
|
+
}
|
|
175
|
+
await showNextQuestion(ctx, scopeKey);
|
|
176
|
+
}
|
|
177
|
+
async function showNextQuestion(ctx, scopeKey) {
|
|
178
|
+
questionManager.nextQuestion(scopeKey);
|
|
179
|
+
if (!ctx.chat) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const threadId = typeof ctx.message?.message_thread_id === "number" ? ctx.message.message_thread_id : null;
|
|
183
|
+
if (questionManager.hasNextQuestion(scopeKey)) {
|
|
184
|
+
await showCurrentQuestion(ctx.api, ctx.chat.id, scopeKey, threadId);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
await showPollSummary(ctx.api, ctx.chat.id, scopeKey, threadId);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function showPollSummary(bot, chatId, scopeKey, threadId) {
|
|
191
|
+
const answers = questionManager.getAllAnswers(scopeKey);
|
|
192
|
+
await sendAllAnswersToAgent(bot, chatId, scopeKey, threadId);
|
|
193
|
+
if (answers.length === 0) {
|
|
194
|
+
await bot.sendMessage(chatId, t("question.completed_no_answers"), getThreadSendOptions(threadId));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
await bot.sendMessage(chatId, formatAnswersSummary(answers), getThreadSendOptions(threadId));
|
|
198
|
+
}
|
|
199
|
+
clearQuestionInteraction("question_completed", scopeKey);
|
|
200
|
+
questionManager.clear(scopeKey);
|
|
201
|
+
}
|
|
202
|
+
async function sendAllAnswersToAgent(bot, chatId, scopeKey, threadId) {
|
|
203
|
+
const currentProject = getCurrentProject(scopeKey);
|
|
204
|
+
const currentSession = getCurrentSession(scopeKey);
|
|
205
|
+
const requestID = questionManager.getRequestID(scopeKey);
|
|
206
|
+
const totalQuestions = questionManager.getTotalQuestions(scopeKey);
|
|
207
|
+
const directory = currentSession?.directory ?? currentProject?.worktree;
|
|
208
|
+
if (!directory) {
|
|
209
|
+
await bot.sendMessage(chatId, t("question.no_active_project"), getThreadSendOptions(threadId));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!requestID) {
|
|
213
|
+
await bot.sendMessage(chatId, t("question.no_active_request"), getThreadSendOptions(threadId));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const allAnswers = [];
|
|
217
|
+
for (let i = 0; i < totalQuestions; i++) {
|
|
218
|
+
const answer = questionManager.getCustomAnswer(i, scopeKey) ||
|
|
219
|
+
questionManager.getSelectedAnswer(i, scopeKey) ||
|
|
220
|
+
"";
|
|
221
|
+
allAnswers.push(answer ? answer.split("\n").filter((part) => part.trim()) : []);
|
|
222
|
+
}
|
|
223
|
+
safeBackgroundTask({
|
|
224
|
+
taskName: "question.reply",
|
|
225
|
+
task: () => opencodeClient.question.reply({
|
|
226
|
+
requestID,
|
|
227
|
+
directory,
|
|
228
|
+
answers: allAnswers,
|
|
229
|
+
}),
|
|
230
|
+
onSuccess: ({ error }) => {
|
|
231
|
+
if (error) {
|
|
232
|
+
logger.error("[QuestionHandler] Failed to send answers via question.reply:", error);
|
|
233
|
+
void bot
|
|
234
|
+
.sendMessage(chatId, t("question.send_answers_error"), getThreadSendOptions(threadId))
|
|
235
|
+
.catch(() => { });
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function formatQuestionText(question, scopeKey) {
|
|
241
|
+
const currentIndex = questionManager.getCurrentIndex(scopeKey);
|
|
242
|
+
const totalQuestions = questionManager.getTotalQuestions(scopeKey);
|
|
243
|
+
const progressText = totalQuestions > 0 ? `${currentIndex + 1}/${totalQuestions}` : "";
|
|
244
|
+
const headerTitle = [progressText, question.header].filter(Boolean).join(" ");
|
|
245
|
+
const header = headerTitle ? `**${headerTitle}**\n\n` : "";
|
|
246
|
+
const multiple = question.multiple ? t("question.multi_hint") : "";
|
|
247
|
+
return `${header}${question.question}${multiple}`;
|
|
248
|
+
}
|
|
249
|
+
function buildQuestionKeyboard(question, selectedOptions, scopeKey) {
|
|
250
|
+
const keyboard = new InlineKeyboard();
|
|
251
|
+
const questionIndex = questionManager.getCurrentIndex(scopeKey);
|
|
252
|
+
question.options.forEach((option, index) => {
|
|
253
|
+
const isSelected = selectedOptions.has(index);
|
|
254
|
+
const icon = isSelected ? "✅ " : "";
|
|
255
|
+
const buttonText = formatButtonText(option.label, option.description, icon);
|
|
256
|
+
keyboard.text(buttonText, `question:select:${questionIndex}:${index}`).row();
|
|
257
|
+
});
|
|
258
|
+
if (question.multiple) {
|
|
259
|
+
keyboard.text(t("question.button.submit"), `question:submit:${questionIndex}`).row();
|
|
260
|
+
}
|
|
261
|
+
keyboard.text(t("question.button.custom"), `question:custom:${questionIndex}`).row();
|
|
262
|
+
keyboard.text(t("question.button.cancel"), `question:cancel:${questionIndex}`);
|
|
263
|
+
return keyboard;
|
|
264
|
+
}
|
|
265
|
+
function formatButtonText(label, description, icon) {
|
|
266
|
+
let text = `${icon}${label}`;
|
|
267
|
+
if (description && icon === "") {
|
|
268
|
+
text += ` - ${description}`;
|
|
269
|
+
}
|
|
270
|
+
if (text.length > MAX_BUTTON_LENGTH) {
|
|
271
|
+
text = text.substring(0, MAX_BUTTON_LENGTH - 3) + "...";
|
|
272
|
+
}
|
|
273
|
+
return text;
|
|
274
|
+
}
|
|
275
|
+
function formatAnswersSummary(answers) {
|
|
276
|
+
let summary = t("question.summary.title");
|
|
277
|
+
answers.forEach((item, index) => {
|
|
278
|
+
summary += t("question.summary.question", {
|
|
279
|
+
index: index + 1,
|
|
280
|
+
question: item.question,
|
|
281
|
+
});
|
|
282
|
+
summary += t("question.summary.answer", { answer: item.answer });
|
|
283
|
+
});
|
|
284
|
+
return summary;
|
|
285
|
+
}
|