@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.
@@ -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
- // Single row with all 3 buttons
162
- keyboard
163
- .text(t("permission.button.allow"), "permission:once")
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
- await handleSelectOption(ctx, questionIndex, parseInt(parts[3], 10));
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, _questionIndex) {
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
- await ctx.editMessageText(t("question.cancelled"));
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 messageIds = questionManager.getMessageIds();
160
- if (messageIds.length > 0 && ctx.chat) {
161
- const lastMessageId = messageIds[messageIds.length - 1];
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.reply(text, { reply_markup: keyboard });
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, handleCompactCancel, } from "./handlers/context.js";
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 { permissionManager } from "../permission/manager.js";
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
- questionManager.clear();
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
- questionManager.clear();
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}, compact=${handledCompactConfirm || handledCompactCancel}, rename=${handledRenameCancel}`);
395
- if (!handledSession &&
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
+ }