@grinev/opencode-telegram-bot 0.3.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.
@@ -7,6 +7,7 @@ import { createMainKeyboard } from "../utils/keyboard.js";
7
7
  import { getStoredAgent } from "../../agent/manager.js";
8
8
  import { pinnedMessageManager } from "../../pinned/manager.js";
9
9
  import { keyboardManager } from "../../keyboard/manager.js";
10
+ import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
10
11
  import { t } from "../../i18n/index.js";
11
12
  /**
12
13
  * Handle model selection callback
@@ -18,6 +19,10 @@ export async function handleModelSelect(ctx) {
18
19
  if (!callbackQuery?.data || !callbackQuery.data.startsWith("model:")) {
19
20
  return false;
20
21
  }
22
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "model");
23
+ if (!isActiveMenu) {
24
+ return true;
25
+ }
21
26
  logger.debug(`[ModelHandler] Received callback: ${callbackQuery.data}`);
22
27
  try {
23
28
  if (ctx.chat) {
@@ -27,7 +32,9 @@ export async function handleModelSelect(ctx) {
27
32
  const parts = callbackQuery.data.split(":");
28
33
  if (parts.length < 3) {
29
34
  logger.error(`[ModelHandler] Invalid callback data format: ${callbackQuery.data}`);
30
- return false;
35
+ clearActiveInlineMenu("model_select_invalid_callback");
36
+ await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
37
+ return true;
31
38
  }
32
39
  const providerID = parts[1];
33
40
  const modelID = parts.slice(2).join(":"); // Handle model IDs that may contain ":"
@@ -54,6 +61,7 @@ export async function handleModelSelect(ctx) {
54
61
  const variantName = formatVariantForButton(modelInfo.variant || "default");
55
62
  const keyboard = createMainKeyboard(currentAgent, modelInfo, contextInfo ?? undefined, variantName);
56
63
  const displayName = formatModelForDisplay(modelInfo.providerID, modelInfo.modelID);
64
+ clearActiveInlineMenu("model_selected");
57
65
  // Send confirmation message with updated keyboard
58
66
  await ctx.answerCallbackQuery({ text: t("model.changed_callback", { name: displayName }) });
59
67
  await ctx.reply(t("model.changed_message", { name: displayName }), {
@@ -64,6 +72,7 @@ export async function handleModelSelect(ctx) {
64
72
  return true;
65
73
  }
66
74
  catch (err) {
75
+ clearActiveInlineMenu("model_select_error");
67
76
  logger.error("[ModelHandler] Error handling model select:", err);
68
77
  await ctx.answerCallbackQuery({ text: t("model.change_error_callback") }).catch(() => { });
69
78
  return false;
@@ -107,7 +116,11 @@ export async function showModelSelectionMenu(ctx) {
107
116
  }
108
117
  const displayName = formatModelForDisplay(currentModel.providerID, currentModel.modelID);
109
118
  const text = t("model.menu.current", { name: displayName });
110
- await ctx.reply(text, { reply_markup: keyboard });
119
+ await replyWithInlineMenu(ctx, {
120
+ menuKind: "model",
121
+ text,
122
+ keyboard,
123
+ });
111
124
  }
112
125
  catch (err) {
113
126
  logger.error("[ModelHandler] Error showing model menu:", err);
@@ -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";
@@ -19,14 +21,17 @@ import { opencodeStartCommand } from "./commands/opencode-start.js";
19
21
  import { opencodeStopCommand } from "./commands/opencode-stop.js";
20
22
  import { handleAgentCommand } from "./commands/agent.js";
21
23
  import { handleModelCommand } from "./commands/model.js";
24
+ import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./commands/rename.js";
22
25
  import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
23
26
  import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
24
27
  import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
25
28
  import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
26
29
  import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
27
- import { handleContextButtonPress, handleCompactConfirm, handleCompactCancel, } from "./handlers/context.js";
30
+ import { handleContextButtonPress, handleCompactConfirm } from "./handlers/context.js";
31
+ import { handleInlineMenuCancel } from "./handlers/inline-menu.js";
28
32
  import { questionManager } from "../question/manager.js";
29
- import { permissionManager } from "../permission/manager.js";
33
+ import { interactionManager } from "../interaction/manager.js";
34
+ import { clearAllInteractionState } from "../interaction/cleanup.js";
30
35
  import { keyboardManager } from "../keyboard/manager.js";
31
36
  import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
32
37
  import { summaryAggregator } from "../summary/aggregator.js";
@@ -167,6 +172,14 @@ async function ensureEventSubscription(directory) {
167
172
  logger.error("Bot or chat ID not available for showing questions");
168
173
  return;
169
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
+ }
170
183
  logger.info(`[Bot] Received ${questions.length} questions from agent, requestID=${requestID}`);
171
184
  questionManager.startQuestions(questions, requestID);
172
185
  await showCurrentQuestion(botInstance.api, chatIdInstance);
@@ -182,7 +195,7 @@ async function ensureEventSubscription(directory) {
182
195
  });
183
196
  }
184
197
  }
185
- questionManager.clear();
198
+ clearAllInteractionState("question_error");
186
199
  });
187
200
  summaryAggregator.setOnPermission(async (request) => {
188
201
  if (!botInstance || !chatIdInstance) {
@@ -297,8 +310,7 @@ async function isSessionBusy(sessionId, directory) {
297
310
  async function resetMismatchedSessionContext() {
298
311
  stopEventListening();
299
312
  summaryAggregator.clear();
300
- questionManager.clear();
301
- permissionManager.clear();
313
+ clearAllInteractionState("session_mismatch_reset");
302
314
  clearSession();
303
315
  keyboardManager.clearContext();
304
316
  if (!pinnedMessageManager.isInitialized()) {
@@ -312,6 +324,7 @@ async function resetMismatchedSessionContext() {
312
324
  }
313
325
  }
314
326
  export function createBot() {
327
+ clearAllInteractionState("bot_startup");
315
328
  const botOptions = {};
316
329
  if (config.telegram.proxyUrl) {
317
330
  const proxyUrl = config.telegram.proxyUrl;
@@ -364,6 +377,16 @@ export function createBot() {
364
377
  });
365
378
  bot.use(authMiddleware);
366
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
+ };
367
390
  bot.command("start", startCommand);
368
391
  bot.command("help", helpCommand);
369
392
  bot.command("status", statusCommand);
@@ -375,10 +398,13 @@ export function createBot() {
375
398
  bot.command("agent", handleAgentCommand);
376
399
  bot.command("model", handleModelCommand);
377
400
  bot.command("stop", stopCommand);
401
+ bot.command("rename", renameCommand);
402
+ bot.on("message:text", unknownCommandMiddleware);
378
403
  bot.on("callback_query:data", async (ctx) => {
379
404
  logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
380
405
  logger.debug(`[Bot] Callback context: from=${ctx.from?.id}, chat=${ctx.chat?.id}`);
381
406
  try {
407
+ const handledInlineCancel = await handleInlineMenuCancel(ctx);
382
408
  const handledSession = await handleSessionSelect(ctx);
383
409
  const handledProject = await handleProjectSelect(ctx);
384
410
  const handledQuestion = await handleQuestionCallback(ctx);
@@ -387,9 +413,10 @@ export function createBot() {
387
413
  const handledModel = await handleModelSelect(ctx);
388
414
  const handledVariant = await handleVariantSelect(ctx);
389
415
  const handledCompactConfirm = await handleCompactConfirm(ctx);
390
- const handledCompactCancel = await handleCompactCancel(ctx);
391
- logger.debug(`[Bot] Callback handled: session=${handledSession}, project=${handledProject}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compact=${handledCompactConfirm || handledCompactCancel}`);
392
- if (!handledSession &&
416
+ const handledRenameCancel = await handleRenameCancel(ctx);
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 &&
393
420
  !handledProject &&
394
421
  !handledQuestion &&
395
422
  !handledPermission &&
@@ -397,13 +424,14 @@ export function createBot() {
397
424
  !handledModel &&
398
425
  !handledVariant &&
399
426
  !handledCompactConfirm &&
400
- !handledCompactCancel) {
427
+ !handledRenameCancel) {
401
428
  logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
402
429
  await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
403
430
  }
404
431
  }
405
432
  catch (err) {
406
433
  logger.error("[Bot] Error handling callback:", err);
434
+ clearAllInteractionState("callback_handler_error");
407
435
  await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
408
436
  }
409
437
  });
@@ -411,6 +439,9 @@ export function createBot() {
411
439
  bot.hears(/^(📋|🛠️|💬|🔍|📝|📄|📦|🤖) \w+ Mode$/, async (ctx) => {
412
440
  logger.debug(`[Bot] Agent mode button pressed: ${ctx.message?.text}`);
413
441
  try {
442
+ if (await blockMenuWhileInteractionActive(ctx)) {
443
+ return;
444
+ }
414
445
  await showAgentSelectionMenu(ctx);
415
446
  }
416
447
  catch (err) {
@@ -423,6 +454,9 @@ export function createBot() {
423
454
  bot.hears(MODEL_BUTTON_TEXT_PATTERN, async (ctx) => {
424
455
  logger.debug(`[Bot] Model button pressed: ${ctx.message?.text}`);
425
456
  try {
457
+ if (await blockMenuWhileInteractionActive(ctx)) {
458
+ return;
459
+ }
426
460
  await showModelSelectionMenu(ctx);
427
461
  }
428
462
  catch (err) {
@@ -434,6 +468,9 @@ export function createBot() {
434
468
  bot.hears(/^📊(?:\s|$)/, async (ctx) => {
435
469
  logger.debug(`[Bot] Context button pressed: ${ctx.message?.text}`);
436
470
  try {
471
+ if (await blockMenuWhileInteractionActive(ctx)) {
472
+ return;
473
+ }
437
474
  await handleContextButtonPress(ctx);
438
475
  }
439
476
  catch (err) {
@@ -446,6 +483,9 @@ export function createBot() {
446
483
  bot.hears(VARIANT_BUTTON_TEXT_PATTERN, async (ctx) => {
447
484
  logger.debug(`[Bot] Variant button pressed: ${ctx.message?.text}`);
448
485
  try {
486
+ if (await blockMenuWhileInteractionActive(ctx)) {
487
+ return;
488
+ }
449
489
  await showVariantSelectionMenu(ctx);
450
490
  }
451
491
  catch (err) {
@@ -496,6 +536,10 @@ export function createBot() {
496
536
  await handleQuestionTextAnswer(ctx);
497
537
  return;
498
538
  }
539
+ const handledRename = await handleRenameTextAnswer(ctx);
540
+ if (handledRename) {
541
+ return;
542
+ }
499
543
  const currentProject = getCurrentProject();
500
544
  if (!currentProject) {
501
545
  await ctx.reply(t("bot.project_not_selected"));
@@ -620,12 +664,16 @@ export function createBot() {
620
664
  }
621
665
  catch (err) {
622
666
  logger.error("Error in prompt handler:", err);
667
+ if (interactionManager.getSnapshot()) {
668
+ clearAllInteractionState("message_handler_error");
669
+ }
623
670
  await ctx.reply(t("error.generic"));
624
671
  }
625
672
  logger.debug("[Bot] message:text handler completed (prompt sent in background)");
626
673
  });
627
674
  bot.catch((err) => {
628
675
  logger.error("[Bot] Unhandled error in bot:", err);
676
+ clearAllInteractionState("bot_unhandled_error");
629
677
  if (err.ctx) {
630
678
  logger.error("[Bot] Error context - update type:", err.ctx.update ? Object.keys(err.ctx.update) : "unknown");
631
679
  }