@grinev/opencode-telegram-bot 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/bot/commands/definitions.js +7 -4
- package/dist/bot/commands/help.js +7 -1
- package/dist/bot/commands/new.js +2 -0
- package/dist/bot/commands/projects.js +18 -9
- package/dist/bot/commands/rename.js +49 -1
- package/dist/bot/commands/sessions.js +13 -2
- package/dist/bot/commands/stop.js +3 -5
- package/dist/bot/handlers/agent.js +12 -1
- package/dist/bot/handlers/context.js +15 -25
- package/dist/bot/handlers/inline-menu.js +119 -0
- package/dist/bot/handlers/model.js +15 -2
- package/dist/bot/handlers/permission.js +50 -5
- package/dist/bot/handlers/question.js +97 -9
- package/dist/bot/handlers/variant.js +12 -1
- package/dist/bot/index.js +49 -9
- package/dist/bot/middleware/interaction-guard.js +80 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/i18n/en.js +23 -4
- package/dist/i18n/ru.js +23 -4
- package/dist/interaction/cleanup.js +24 -0
- package/dist/interaction/guard.js +87 -0
- package/dist/interaction/manager.js +106 -0
- package/dist/interaction/types.js +1 -0
- package/dist/permission/manager.js +6 -0
- package/dist/question/manager.js +33 -0
- package/dist/rename/manager.js +3 -0
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import { opencodeClient } from "../../opencode/client.js";
|
|
|
4
4
|
import { getCurrentProject } from "../../settings/manager.js";
|
|
5
5
|
import { getCurrentSession } from "../../session/manager.js";
|
|
6
6
|
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
7
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
7
8
|
import { logger } from "../../utils/logger.js";
|
|
8
9
|
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
9
10
|
import { t } from "../../i18n/index.js";
|
|
@@ -35,6 +36,20 @@ const PERMISSION_EMOJIS = {
|
|
|
35
36
|
task: "⚙️",
|
|
36
37
|
lsp: "🔧",
|
|
37
38
|
};
|
|
39
|
+
function getCallbackMessageId(ctx) {
|
|
40
|
+
const message = ctx.callbackQuery?.message;
|
|
41
|
+
if (!message || !("message_id" in message)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const messageId = message.message_id;
|
|
45
|
+
return typeof messageId === "number" ? messageId : null;
|
|
46
|
+
}
|
|
47
|
+
function clearPermissionInteraction(reason) {
|
|
48
|
+
const state = interactionManager.getSnapshot();
|
|
49
|
+
if (state?.kind === "permission") {
|
|
50
|
+
interactionManager.clear(reason);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
38
53
|
/**
|
|
39
54
|
* Handle permission callback from inline buttons
|
|
40
55
|
*/
|
|
@@ -47,6 +62,12 @@ export async function handlePermissionCallback(ctx) {
|
|
|
47
62
|
}
|
|
48
63
|
logger.debug(`[PermissionHandler] Received callback: ${data}`);
|
|
49
64
|
if (!permissionManager.isActive()) {
|
|
65
|
+
clearPermissionInteraction("permission_inactive_callback");
|
|
66
|
+
await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
70
|
+
if (!permissionManager.isActiveMessage(callbackMessageId)) {
|
|
50
71
|
await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
|
|
51
72
|
return true;
|
|
52
73
|
}
|
|
@@ -74,6 +95,8 @@ async function handlePermissionReply(ctx, reply) {
|
|
|
74
95
|
const chatId = ctx.chat?.id;
|
|
75
96
|
const directory = currentSession?.directory ?? currentProject?.worktree;
|
|
76
97
|
if (!requestID || !directory || !chatId) {
|
|
98
|
+
permissionManager.clear();
|
|
99
|
+
clearPermissionInteraction("permission_invalid_runtime_context");
|
|
77
100
|
await ctx.answerCallbackQuery({
|
|
78
101
|
text: t("permission.no_active_request_callback"),
|
|
79
102
|
show_alert: true,
|
|
@@ -112,13 +135,29 @@ async function handlePermissionReply(ctx, reply) {
|
|
|
112
135
|
},
|
|
113
136
|
});
|
|
114
137
|
permissionManager.clear();
|
|
138
|
+
clearPermissionInteraction("permission_replied");
|
|
115
139
|
}
|
|
116
140
|
/**
|
|
117
141
|
* Show permission request message with inline buttons
|
|
118
142
|
*/
|
|
119
143
|
export async function showPermissionRequest(bot, chatId, request) {
|
|
120
144
|
logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);
|
|
145
|
+
const previousMessageId = permissionManager.getMessageId();
|
|
146
|
+
const hadActivePermission = permissionManager.isActive();
|
|
147
|
+
if (hadActivePermission && previousMessageId !== null) {
|
|
148
|
+
logger.debug(`[PermissionHandler] Replacing active permission request, deleting previous message: ${previousMessageId}`);
|
|
149
|
+
await bot.deleteMessage(chatId, previousMessageId).catch((err) => {
|
|
150
|
+
logger.debug("[PermissionHandler] Failed to delete previous permission message:", err);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
121
153
|
permissionManager.startPermission(request);
|
|
154
|
+
interactionManager.start({
|
|
155
|
+
kind: "permission",
|
|
156
|
+
expectedInput: "callback",
|
|
157
|
+
metadata: {
|
|
158
|
+
requestID: request.id,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
122
161
|
const text = formatPermissionText(request);
|
|
123
162
|
const keyboard = buildPermissionKeyboard();
|
|
124
163
|
try {
|
|
@@ -128,9 +167,17 @@ export async function showPermissionRequest(bot, chatId, request) {
|
|
|
128
167
|
});
|
|
129
168
|
logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
|
|
130
169
|
permissionManager.setMessageId(message.message_id);
|
|
170
|
+
interactionManager.transition({
|
|
171
|
+
metadata: {
|
|
172
|
+
requestID: request.id,
|
|
173
|
+
messageId: message.message_id,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
131
176
|
summaryAggregator.stopTypingIndicator();
|
|
132
177
|
}
|
|
133
178
|
catch (err) {
|
|
179
|
+
permissionManager.clear();
|
|
180
|
+
clearPermissionInteraction("permission_message_send_failed");
|
|
134
181
|
logger.error("[PermissionHandler] Failed to send permission message:", err);
|
|
135
182
|
throw err;
|
|
136
183
|
}
|
|
@@ -158,10 +205,8 @@ function formatPermissionText(request) {
|
|
|
158
205
|
*/
|
|
159
206
|
function buildPermissionKeyboard() {
|
|
160
207
|
const keyboard = new InlineKeyboard();
|
|
161
|
-
|
|
162
|
-
keyboard
|
|
163
|
-
|
|
164
|
-
.text(t("permission.button.always"), "permission:always")
|
|
165
|
-
.text(t("permission.button.reject"), "permission:reject");
|
|
208
|
+
keyboard.text(t("permission.button.allow"), "permission:once").row();
|
|
209
|
+
keyboard.text(t("permission.button.always"), "permission:always").row();
|
|
210
|
+
keyboard.text(t("permission.button.reject"), "permission:reject");
|
|
166
211
|
return keyboard;
|
|
167
212
|
}
|
|
@@ -4,10 +4,51 @@ import { opencodeClient } from "../../opencode/client.js";
|
|
|
4
4
|
import { getCurrentProject } from "../../settings/manager.js";
|
|
5
5
|
import { getCurrentSession } from "../../session/manager.js";
|
|
6
6
|
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
7
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
7
8
|
import { logger } from "../../utils/logger.js";
|
|
8
9
|
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
9
10
|
import { t } from "../../i18n/index.js";
|
|
10
11
|
const MAX_BUTTON_LENGTH = 60;
|
|
12
|
+
function getCallbackMessageId(ctx) {
|
|
13
|
+
const message = ctx.callbackQuery?.message;
|
|
14
|
+
if (!message || !("message_id" in message)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const messageId = message.message_id;
|
|
18
|
+
return typeof messageId === "number" ? messageId : null;
|
|
19
|
+
}
|
|
20
|
+
function clearQuestionInteraction(reason) {
|
|
21
|
+
const state = interactionManager.getSnapshot();
|
|
22
|
+
if (state?.kind === "question") {
|
|
23
|
+
interactionManager.clear(reason);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function syncQuestionInteractionState(expectedInput, questionIndex, messageId) {
|
|
27
|
+
const metadata = {
|
|
28
|
+
questionIndex,
|
|
29
|
+
inputMode: expectedInput === "mixed" ? "custom" : "options",
|
|
30
|
+
};
|
|
31
|
+
const requestID = questionManager.getRequestID();
|
|
32
|
+
if (requestID) {
|
|
33
|
+
metadata.requestID = requestID;
|
|
34
|
+
}
|
|
35
|
+
if (messageId !== null) {
|
|
36
|
+
metadata.messageId = messageId;
|
|
37
|
+
}
|
|
38
|
+
const state = interactionManager.getSnapshot();
|
|
39
|
+
if (state?.kind === "question") {
|
|
40
|
+
interactionManager.transition({
|
|
41
|
+
expectedInput,
|
|
42
|
+
metadata,
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
interactionManager.start({
|
|
47
|
+
kind: "question",
|
|
48
|
+
expectedInput,
|
|
49
|
+
metadata,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
11
52
|
export async function handleQuestionCallback(ctx) {
|
|
12
53
|
const data = ctx.callbackQuery?.data;
|
|
13
54
|
if (!data)
|
|
@@ -17,16 +58,36 @@ export async function handleQuestionCallback(ctx) {
|
|
|
17
58
|
}
|
|
18
59
|
logger.debug(`[QuestionHandler] Received callback: ${data}`);
|
|
19
60
|
if (!questionManager.isActive()) {
|
|
61
|
+
clearQuestionInteraction("question_inactive_callback");
|
|
62
|
+
await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
66
|
+
if (!questionManager.isActiveMessage(callbackMessageId)) {
|
|
20
67
|
await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
|
|
21
68
|
return true;
|
|
22
69
|
}
|
|
23
70
|
const parts = data.split(":");
|
|
24
71
|
const action = parts[1];
|
|
25
72
|
const questionIndex = parseInt(parts[2], 10);
|
|
73
|
+
if (Number.isNaN(questionIndex) || questionIndex !== questionManager.getCurrentIndex()) {
|
|
74
|
+
await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
26
77
|
try {
|
|
27
78
|
switch (action) {
|
|
28
79
|
case "select":
|
|
29
|
-
|
|
80
|
+
{
|
|
81
|
+
const optionIndex = parseInt(parts[3], 10);
|
|
82
|
+
if (Number.isNaN(optionIndex)) {
|
|
83
|
+
await ctx.answerCallbackQuery({
|
|
84
|
+
text: t("question.processing_error_callback"),
|
|
85
|
+
show_alert: true,
|
|
86
|
+
});
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
await handleSelectOption(ctx, questionIndex, optionIndex);
|
|
90
|
+
}
|
|
30
91
|
break;
|
|
31
92
|
case "submit":
|
|
32
93
|
await handleSubmitAnswer(ctx, questionIndex);
|
|
@@ -37,6 +98,12 @@ export async function handleQuestionCallback(ctx) {
|
|
|
37
98
|
case "cancel":
|
|
38
99
|
await handleCancelPoll(ctx);
|
|
39
100
|
break;
|
|
101
|
+
default:
|
|
102
|
+
await ctx.answerCallbackQuery({
|
|
103
|
+
text: t("question.processing_error_callback"),
|
|
104
|
+
show_alert: true,
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
40
107
|
}
|
|
41
108
|
}
|
|
42
109
|
catch (err) {
|
|
@@ -55,6 +122,10 @@ async function handleSelectOption(ctx, questionIndex, optionIndex) {
|
|
|
55
122
|
logger.debug("[QuestionHandler] No current question");
|
|
56
123
|
return;
|
|
57
124
|
}
|
|
125
|
+
if (questionManager.isWaitingForCustomInput(questionIndex)) {
|
|
126
|
+
questionManager.clearCustomInput();
|
|
127
|
+
syncQuestionInteractionState("callback", questionIndex, questionManager.getActiveMessageId());
|
|
128
|
+
}
|
|
58
129
|
questionManager.selectOption(questionIndex, optionIndex);
|
|
59
130
|
if (question.multiple) {
|
|
60
131
|
logger.debug("[QuestionHandler] Multiple choice mode, updating message");
|
|
@@ -74,6 +145,10 @@ async function handleSelectOption(ctx, questionIndex, optionIndex) {
|
|
|
74
145
|
}
|
|
75
146
|
}
|
|
76
147
|
async function handleSubmitAnswer(ctx, questionIndex) {
|
|
148
|
+
if (questionManager.isWaitingForCustomInput(questionIndex)) {
|
|
149
|
+
questionManager.clearCustomInput();
|
|
150
|
+
syncQuestionInteractionState("callback", questionIndex, questionManager.getActiveMessageId());
|
|
151
|
+
}
|
|
77
152
|
const answer = questionManager.getSelectedAnswer(questionIndex);
|
|
78
153
|
if (!answer) {
|
|
79
154
|
await ctx.answerCallbackQuery({
|
|
@@ -90,7 +165,9 @@ async function handleSubmitAnswer(ctx, questionIndex) {
|
|
|
90
165
|
// All answers will be sent together after the user answers all questions
|
|
91
166
|
await showNextQuestion(ctx);
|
|
92
167
|
}
|
|
93
|
-
async function handleCustomAnswer(ctx,
|
|
168
|
+
async function handleCustomAnswer(ctx, questionIndex) {
|
|
169
|
+
questionManager.startCustomInput(questionIndex);
|
|
170
|
+
syncQuestionInteractionState("mixed", questionIndex, questionManager.getActiveMessageId());
|
|
94
171
|
await ctx.answerCallbackQuery({
|
|
95
172
|
text: t("question.enter_custom_callback"),
|
|
96
173
|
show_alert: true,
|
|
@@ -98,8 +175,10 @@ async function handleCustomAnswer(ctx, _questionIndex) {
|
|
|
98
175
|
}
|
|
99
176
|
async function handleCancelPoll(ctx) {
|
|
100
177
|
questionManager.cancel();
|
|
101
|
-
|
|
178
|
+
clearQuestionInteraction("question_cancelled");
|
|
179
|
+
await ctx.editMessageText(t("question.cancelled")).catch(() => { });
|
|
102
180
|
await ctx.answerCallbackQuery();
|
|
181
|
+
questionManager.clear();
|
|
103
182
|
}
|
|
104
183
|
async function updateQuestionMessage(ctx) {
|
|
105
184
|
const question = questionManager.getCurrentQuestion();
|
|
@@ -137,9 +216,13 @@ export async function showCurrentQuestion(bot, chatId) {
|
|
|
137
216
|
});
|
|
138
217
|
logger.debug(`[QuestionHandler] Message sent, messageId=${message.message_id}`);
|
|
139
218
|
questionManager.addMessageId(message.message_id);
|
|
219
|
+
questionManager.setActiveMessageId(message.message_id);
|
|
220
|
+
syncQuestionInteractionState("callback", questionManager.getCurrentIndex(), questionManager.getActiveMessageId());
|
|
140
221
|
summaryAggregator.stopTypingIndicator();
|
|
141
222
|
}
|
|
142
223
|
catch (err) {
|
|
224
|
+
questionManager.clear();
|
|
225
|
+
clearQuestionInteraction("question_message_send_failed");
|
|
143
226
|
logger.error("[QuestionHandler] Failed to send question message:", err);
|
|
144
227
|
throw err;
|
|
145
228
|
}
|
|
@@ -149,17 +232,21 @@ export async function handleQuestionTextAnswer(ctx) {
|
|
|
149
232
|
if (!text)
|
|
150
233
|
return;
|
|
151
234
|
const currentIndex = questionManager.getCurrentIndex();
|
|
235
|
+
if (!questionManager.isWaitingForCustomInput(currentIndex)) {
|
|
236
|
+
await ctx.reply(t("question.use_custom_button_first"));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
152
239
|
if (questionManager.hasCustomAnswer(currentIndex)) {
|
|
153
240
|
await ctx.reply(t("question.answer_already_received"));
|
|
154
241
|
return;
|
|
155
242
|
}
|
|
156
243
|
logger.debug(`[QuestionHandler] Custom text answer for question ${currentIndex}: ${text}`);
|
|
157
244
|
questionManager.setCustomAnswer(currentIndex, text);
|
|
245
|
+
questionManager.clearCustomInput();
|
|
158
246
|
// Delete the previous question message
|
|
159
|
-
const
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
await ctx.api.deleteMessage(ctx.chat.id, lastMessageId).catch(() => { });
|
|
247
|
+
const activeMessageId = questionManager.getActiveMessageId();
|
|
248
|
+
if (activeMessageId !== null && ctx.chat) {
|
|
249
|
+
await ctx.api.deleteMessage(ctx.chat.id, activeMessageId).catch(() => { });
|
|
163
250
|
}
|
|
164
251
|
// DO NOT send the answer immediately - move to the next question
|
|
165
252
|
// All answers will be sent together after the user answers all questions
|
|
@@ -190,6 +277,7 @@ async function showPollSummary(bot, chatId) {
|
|
|
190
277
|
const summary = formatAnswersSummary(answers);
|
|
191
278
|
await bot.sendMessage(chatId, summary);
|
|
192
279
|
}
|
|
280
|
+
clearQuestionInteraction("question_completed");
|
|
193
281
|
questionManager.clear();
|
|
194
282
|
logger.debug("[QuestionHandler] Poll completed and cleared");
|
|
195
283
|
}
|
|
@@ -271,10 +359,10 @@ function buildQuestionKeyboard(question, selectedOptions) {
|
|
|
271
359
|
keyboard.text(buttonText, callbackData).row();
|
|
272
360
|
});
|
|
273
361
|
if (question.multiple) {
|
|
274
|
-
keyboard.text(t("question.button.submit"), `question:submit:${questionIndex}`);
|
|
362
|
+
keyboard.text(t("question.button.submit"), `question:submit:${questionIndex}`).row();
|
|
275
363
|
logger.debug(`[QuestionHandler] Added submit button`);
|
|
276
364
|
}
|
|
277
|
-
keyboard.text(t("question.button.custom"), `question:custom:${questionIndex}`);
|
|
365
|
+
keyboard.text(t("question.button.custom"), `question:custom:${questionIndex}`).row();
|
|
278
366
|
logger.debug(`[QuestionHandler] Added custom answer button`);
|
|
279
367
|
keyboard.text(t("question.button.cancel"), `question:cancel:${questionIndex}`);
|
|
280
368
|
logger.debug(`[QuestionHandler] Added cancel button`);
|
|
@@ -6,6 +6,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
6
6
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
7
7
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
8
8
|
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
9
|
+
import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
|
|
9
10
|
import { t } from "../../i18n/index.js";
|
|
10
11
|
/**
|
|
11
12
|
* Handle variant selection callback
|
|
@@ -17,6 +18,10 @@ export async function handleVariantSelect(ctx) {
|
|
|
17
18
|
if (!callbackQuery?.data || !callbackQuery.data.startsWith("variant:")) {
|
|
18
19
|
return false;
|
|
19
20
|
}
|
|
21
|
+
const isActiveMenu = await ensureActiveInlineMenu(ctx, "variant");
|
|
22
|
+
if (!isActiveMenu) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
20
25
|
logger.debug(`[VariantHandler] Received callback: ${callbackQuery.data}`);
|
|
21
26
|
try {
|
|
22
27
|
if (ctx.chat) {
|
|
@@ -54,6 +59,7 @@ export async function handleVariantSelect(ctx) {
|
|
|
54
59
|
const keyboard = createMainKeyboard(currentAgent, updatedModel, contextInfo ?? undefined, variantName);
|
|
55
60
|
// Send confirmation message with updated keyboard
|
|
56
61
|
const displayName = formatVariantForDisplay(variantId);
|
|
62
|
+
clearActiveInlineMenu("variant_selected");
|
|
57
63
|
await ctx.answerCallbackQuery({ text: t("variant.changed_callback", { name: displayName }) });
|
|
58
64
|
await ctx.reply(t("variant.changed_message", { name: displayName }), {
|
|
59
65
|
reply_markup: keyboard,
|
|
@@ -63,6 +69,7 @@ export async function handleVariantSelect(ctx) {
|
|
|
63
69
|
return true;
|
|
64
70
|
}
|
|
65
71
|
catch (err) {
|
|
72
|
+
clearActiveInlineMenu("variant_select_error");
|
|
66
73
|
logger.error("[VariantHandler] Error handling variant select:", err);
|
|
67
74
|
await ctx.answerCallbackQuery({ text: t("variant.change_error_callback") }).catch(() => { });
|
|
68
75
|
return false;
|
|
@@ -118,7 +125,11 @@ export async function showVariantSelectionMenu(ctx) {
|
|
|
118
125
|
}
|
|
119
126
|
const displayName = formatVariantForDisplay(currentVariant);
|
|
120
127
|
const text = t("variant.menu.current", { name: displayName });
|
|
121
|
-
await ctx
|
|
128
|
+
await replyWithInlineMenu(ctx, {
|
|
129
|
+
menuKind: "variant",
|
|
130
|
+
text,
|
|
131
|
+
keyboard,
|
|
132
|
+
});
|
|
122
133
|
}
|
|
123
134
|
catch (err) {
|
|
124
135
|
logger.error("[VariantHandler] Error showing variant menu:", err);
|
package/dist/bot/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { SocksProxyAgent } from "socks-proxy-agent";
|
|
|
6
6
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
7
7
|
import { config } from "../config.js";
|
|
8
8
|
import { authMiddleware } from "./middleware/auth.js";
|
|
9
|
+
import { interactionGuardMiddleware } from "./middleware/interaction-guard.js";
|
|
10
|
+
import { unknownCommandMiddleware } from "./middleware/unknown-command.js";
|
|
9
11
|
import { BOT_COMMANDS } from "./commands/definitions.js";
|
|
10
12
|
import { startCommand } from "./commands/start.js";
|
|
11
13
|
import { helpCommand } from "./commands/help.js";
|
|
@@ -25,9 +27,11 @@ import { handlePermissionCallback, showPermissionRequest } from "./handlers/perm
|
|
|
25
27
|
import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
|
|
26
28
|
import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
|
|
27
29
|
import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
|
|
28
|
-
import { handleContextButtonPress, handleCompactConfirm
|
|
30
|
+
import { handleContextButtonPress, handleCompactConfirm } from "./handlers/context.js";
|
|
31
|
+
import { handleInlineMenuCancel } from "./handlers/inline-menu.js";
|
|
29
32
|
import { questionManager } from "../question/manager.js";
|
|
30
|
-
import {
|
|
33
|
+
import { interactionManager } from "../interaction/manager.js";
|
|
34
|
+
import { clearAllInteractionState } from "../interaction/cleanup.js";
|
|
31
35
|
import { keyboardManager } from "../keyboard/manager.js";
|
|
32
36
|
import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
|
|
33
37
|
import { summaryAggregator } from "../summary/aggregator.js";
|
|
@@ -168,6 +172,14 @@ async function ensureEventSubscription(directory) {
|
|
|
168
172
|
logger.error("Bot or chat ID not available for showing questions");
|
|
169
173
|
return;
|
|
170
174
|
}
|
|
175
|
+
if (questionManager.isActive()) {
|
|
176
|
+
logger.warn("[Bot] Replacing active poll with a new one");
|
|
177
|
+
const previousMessageIds = questionManager.getMessageIds();
|
|
178
|
+
for (const messageId of previousMessageIds) {
|
|
179
|
+
await botInstance.api.deleteMessage(chatIdInstance, messageId).catch(() => { });
|
|
180
|
+
}
|
|
181
|
+
clearAllInteractionState("question_replaced_by_new_poll");
|
|
182
|
+
}
|
|
171
183
|
logger.info(`[Bot] Received ${questions.length} questions from agent, requestID=${requestID}`);
|
|
172
184
|
questionManager.startQuestions(questions, requestID);
|
|
173
185
|
await showCurrentQuestion(botInstance.api, chatIdInstance);
|
|
@@ -183,7 +195,7 @@ async function ensureEventSubscription(directory) {
|
|
|
183
195
|
});
|
|
184
196
|
}
|
|
185
197
|
}
|
|
186
|
-
|
|
198
|
+
clearAllInteractionState("question_error");
|
|
187
199
|
});
|
|
188
200
|
summaryAggregator.setOnPermission(async (request) => {
|
|
189
201
|
if (!botInstance || !chatIdInstance) {
|
|
@@ -298,8 +310,7 @@ async function isSessionBusy(sessionId, directory) {
|
|
|
298
310
|
async function resetMismatchedSessionContext() {
|
|
299
311
|
stopEventListening();
|
|
300
312
|
summaryAggregator.clear();
|
|
301
|
-
|
|
302
|
-
permissionManager.clear();
|
|
313
|
+
clearAllInteractionState("session_mismatch_reset");
|
|
303
314
|
clearSession();
|
|
304
315
|
keyboardManager.clearContext();
|
|
305
316
|
if (!pinnedMessageManager.isInitialized()) {
|
|
@@ -313,6 +324,7 @@ async function resetMismatchedSessionContext() {
|
|
|
313
324
|
}
|
|
314
325
|
}
|
|
315
326
|
export function createBot() {
|
|
327
|
+
clearAllInteractionState("bot_startup");
|
|
316
328
|
const botOptions = {};
|
|
317
329
|
if (config.telegram.proxyUrl) {
|
|
318
330
|
const proxyUrl = config.telegram.proxyUrl;
|
|
@@ -365,6 +377,16 @@ export function createBot() {
|
|
|
365
377
|
});
|
|
366
378
|
bot.use(authMiddleware);
|
|
367
379
|
bot.use(ensureCommandsInitialized);
|
|
380
|
+
bot.use(interactionGuardMiddleware);
|
|
381
|
+
const blockMenuWhileInteractionActive = async (ctx) => {
|
|
382
|
+
const activeInteraction = interactionManager.getSnapshot();
|
|
383
|
+
if (!activeInteraction) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
logger.debug(`[Bot] Blocking menu open while interaction active: kind=${activeInteraction.kind}, expectedInput=${activeInteraction.expectedInput}`);
|
|
387
|
+
await ctx.reply(t("interaction.blocked.finish_current"));
|
|
388
|
+
return true;
|
|
389
|
+
};
|
|
368
390
|
bot.command("start", startCommand);
|
|
369
391
|
bot.command("help", helpCommand);
|
|
370
392
|
bot.command("status", statusCommand);
|
|
@@ -377,10 +399,12 @@ export function createBot() {
|
|
|
377
399
|
bot.command("model", handleModelCommand);
|
|
378
400
|
bot.command("stop", stopCommand);
|
|
379
401
|
bot.command("rename", renameCommand);
|
|
402
|
+
bot.on("message:text", unknownCommandMiddleware);
|
|
380
403
|
bot.on("callback_query:data", async (ctx) => {
|
|
381
404
|
logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
|
|
382
405
|
logger.debug(`[Bot] Callback context: from=${ctx.from?.id}, chat=${ctx.chat?.id}`);
|
|
383
406
|
try {
|
|
407
|
+
const handledInlineCancel = await handleInlineMenuCancel(ctx);
|
|
384
408
|
const handledSession = await handleSessionSelect(ctx);
|
|
385
409
|
const handledProject = await handleProjectSelect(ctx);
|
|
386
410
|
const handledQuestion = await handleQuestionCallback(ctx);
|
|
@@ -389,10 +413,10 @@ export function createBot() {
|
|
|
389
413
|
const handledModel = await handleModelSelect(ctx);
|
|
390
414
|
const handledVariant = await handleVariantSelect(ctx);
|
|
391
415
|
const handledCompactConfirm = await handleCompactConfirm(ctx);
|
|
392
|
-
const handledCompactCancel = await handleCompactCancel(ctx);
|
|
393
416
|
const handledRenameCancel = await handleRenameCancel(ctx);
|
|
394
|
-
logger.debug(`[Bot] Callback handled: session=${handledSession}, project=${handledProject}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant},
|
|
395
|
-
if (!
|
|
417
|
+
logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, rename=${handledRenameCancel}`);
|
|
418
|
+
if (!handledInlineCancel &&
|
|
419
|
+
!handledSession &&
|
|
396
420
|
!handledProject &&
|
|
397
421
|
!handledQuestion &&
|
|
398
422
|
!handledPermission &&
|
|
@@ -400,7 +424,6 @@ export function createBot() {
|
|
|
400
424
|
!handledModel &&
|
|
401
425
|
!handledVariant &&
|
|
402
426
|
!handledCompactConfirm &&
|
|
403
|
-
!handledCompactCancel &&
|
|
404
427
|
!handledRenameCancel) {
|
|
405
428
|
logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
|
|
406
429
|
await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
|
|
@@ -408,6 +431,7 @@ export function createBot() {
|
|
|
408
431
|
}
|
|
409
432
|
catch (err) {
|
|
410
433
|
logger.error("[Bot] Error handling callback:", err);
|
|
434
|
+
clearAllInteractionState("callback_handler_error");
|
|
411
435
|
await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
|
|
412
436
|
}
|
|
413
437
|
});
|
|
@@ -415,6 +439,9 @@ export function createBot() {
|
|
|
415
439
|
bot.hears(/^(📋|🛠️|💬|🔍|📝|📄|📦|🤖) \w+ Mode$/, async (ctx) => {
|
|
416
440
|
logger.debug(`[Bot] Agent mode button pressed: ${ctx.message?.text}`);
|
|
417
441
|
try {
|
|
442
|
+
if (await blockMenuWhileInteractionActive(ctx)) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
418
445
|
await showAgentSelectionMenu(ctx);
|
|
419
446
|
}
|
|
420
447
|
catch (err) {
|
|
@@ -427,6 +454,9 @@ export function createBot() {
|
|
|
427
454
|
bot.hears(MODEL_BUTTON_TEXT_PATTERN, async (ctx) => {
|
|
428
455
|
logger.debug(`[Bot] Model button pressed: ${ctx.message?.text}`);
|
|
429
456
|
try {
|
|
457
|
+
if (await blockMenuWhileInteractionActive(ctx)) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
430
460
|
await showModelSelectionMenu(ctx);
|
|
431
461
|
}
|
|
432
462
|
catch (err) {
|
|
@@ -438,6 +468,9 @@ export function createBot() {
|
|
|
438
468
|
bot.hears(/^📊(?:\s|$)/, async (ctx) => {
|
|
439
469
|
logger.debug(`[Bot] Context button pressed: ${ctx.message?.text}`);
|
|
440
470
|
try {
|
|
471
|
+
if (await blockMenuWhileInteractionActive(ctx)) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
441
474
|
await handleContextButtonPress(ctx);
|
|
442
475
|
}
|
|
443
476
|
catch (err) {
|
|
@@ -450,6 +483,9 @@ export function createBot() {
|
|
|
450
483
|
bot.hears(VARIANT_BUTTON_TEXT_PATTERN, async (ctx) => {
|
|
451
484
|
logger.debug(`[Bot] Variant button pressed: ${ctx.message?.text}`);
|
|
452
485
|
try {
|
|
486
|
+
if (await blockMenuWhileInteractionActive(ctx)) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
453
489
|
await showVariantSelectionMenu(ctx);
|
|
454
490
|
}
|
|
455
491
|
catch (err) {
|
|
@@ -628,12 +664,16 @@ export function createBot() {
|
|
|
628
664
|
}
|
|
629
665
|
catch (err) {
|
|
630
666
|
logger.error("Error in prompt handler:", err);
|
|
667
|
+
if (interactionManager.getSnapshot()) {
|
|
668
|
+
clearAllInteractionState("message_handler_error");
|
|
669
|
+
}
|
|
631
670
|
await ctx.reply(t("error.generic"));
|
|
632
671
|
}
|
|
633
672
|
logger.debug("[Bot] message:text handler completed (prompt sent in background)");
|
|
634
673
|
});
|
|
635
674
|
bot.catch((err) => {
|
|
636
675
|
logger.error("[Bot] Unhandled error in bot:", err);
|
|
676
|
+
clearAllInteractionState("bot_unhandled_error");
|
|
637
677
|
if (err.ctx) {
|
|
638
678
|
logger.error("[Bot] Error context - update type:", err.ctx.update ? Object.keys(err.ctx.update) : "unknown");
|
|
639
679
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { resolveInteractionGuardDecision } from "../../interaction/guard.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
3
|
+
import { t } from "../../i18n/index.js";
|
|
4
|
+
function getInteractionBlockedMessage(reason, interactionKind) {
|
|
5
|
+
if (interactionKind === "permission") {
|
|
6
|
+
switch (reason) {
|
|
7
|
+
case "command_not_allowed":
|
|
8
|
+
return t("permission.blocked.command_not_allowed");
|
|
9
|
+
case "expected_callback":
|
|
10
|
+
case "expected_command":
|
|
11
|
+
case "expected_text":
|
|
12
|
+
default:
|
|
13
|
+
return t("permission.blocked.expected_reply");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (interactionKind === "inline") {
|
|
17
|
+
switch (reason) {
|
|
18
|
+
case "command_not_allowed":
|
|
19
|
+
return t("inline.blocked.command_not_allowed");
|
|
20
|
+
case "expected_callback":
|
|
21
|
+
case "expected_command":
|
|
22
|
+
case "expected_text":
|
|
23
|
+
default:
|
|
24
|
+
return t("inline.blocked.expected_choice");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (interactionKind === "question") {
|
|
28
|
+
switch (reason) {
|
|
29
|
+
case "command_not_allowed":
|
|
30
|
+
return t("question.blocked.command_not_allowed");
|
|
31
|
+
case "expected_callback":
|
|
32
|
+
case "expected_command":
|
|
33
|
+
case "expected_text":
|
|
34
|
+
default:
|
|
35
|
+
return t("question.blocked.expected_answer");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (interactionKind === "rename") {
|
|
39
|
+
switch (reason) {
|
|
40
|
+
case "command_not_allowed":
|
|
41
|
+
return t("rename.blocked.command_not_allowed");
|
|
42
|
+
case "expected_callback":
|
|
43
|
+
case "expected_command":
|
|
44
|
+
case "expected_text":
|
|
45
|
+
default:
|
|
46
|
+
return t("rename.blocked.expected_name");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
switch (reason) {
|
|
50
|
+
case "expired":
|
|
51
|
+
return t("interaction.blocked.expired");
|
|
52
|
+
case "expected_callback":
|
|
53
|
+
return t("interaction.blocked.expected_callback");
|
|
54
|
+
case "expected_command":
|
|
55
|
+
return t("interaction.blocked.expected_command");
|
|
56
|
+
case "command_not_allowed":
|
|
57
|
+
return t("interaction.blocked.command_not_allowed");
|
|
58
|
+
case "expected_text":
|
|
59
|
+
default:
|
|
60
|
+
return t("interaction.blocked.expected_text");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function interactionGuardMiddleware(ctx, next) {
|
|
64
|
+
const decision = resolveInteractionGuardDecision(ctx);
|
|
65
|
+
if (decision.allow) {
|
|
66
|
+
await next();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const message = getInteractionBlockedMessage(decision.reason, decision.state?.kind);
|
|
70
|
+
logger.debug(`[InteractionGuard] Blocked input: interactionKind=${decision.state?.kind || "none"}, inputType=${decision.inputType}, reason=${decision.reason || "unknown"}, command=${decision.command || "-"}`);
|
|
71
|
+
if (ctx.callbackQuery) {
|
|
72
|
+
await ctx.answerCallbackQuery({ text: message }).catch(() => { });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (ctx.chat) {
|
|
76
|
+
await ctx.reply(message).catch((err) => {
|
|
77
|
+
logger.error("[InteractionGuard] Failed to send blocked input message:", err);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { extractCommandName, isKnownCommand } from "../utils/commands.js";
|
|
2
|
+
import { logger } from "../../utils/logger.js";
|
|
3
|
+
import { t } from "../../i18n/index.js";
|
|
4
|
+
export async function unknownCommandMiddleware(ctx, next) {
|
|
5
|
+
const text = ctx.message?.text;
|
|
6
|
+
if (!text) {
|
|
7
|
+
await next();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const commandName = extractCommandName(text);
|
|
11
|
+
if (!commandName) {
|
|
12
|
+
await next();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (isKnownCommand(commandName)) {
|
|
16
|
+
await next();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const commandToken = text.trim().split(/\s+/)[0];
|
|
20
|
+
logger.debug(`[Bot] Unknown slash command received: ${commandToken}`);
|
|
21
|
+
await ctx.reply(t("bot.unknown_command", { command: commandToken }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BOT_COMMANDS } from "../commands/definitions.js";
|
|
2
|
+
const KNOWN_COMMANDS = new Set(["start", ...BOT_COMMANDS.map((item) => item.command)]);
|
|
3
|
+
export function extractCommandName(text) {
|
|
4
|
+
const trimmed = text.trim();
|
|
5
|
+
if (!trimmed.startsWith("/")) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const token = trimmed.split(/\s+/)[0];
|
|
9
|
+
const withoutSlash = token.slice(1);
|
|
10
|
+
if (!withoutSlash) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const withoutMention = withoutSlash.split("@")[0].toLowerCase();
|
|
14
|
+
if (!withoutMention) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return withoutMention;
|
|
18
|
+
}
|
|
19
|
+
export function isKnownCommand(commandName) {
|
|
20
|
+
return KNOWN_COMMANDS.has(commandName);
|
|
21
|
+
}
|