@grinev/opencode-telegram-bot 0.4.0 → 0.6.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.
Files changed (36) hide show
  1. package/.env.example +11 -0
  2. package/README.md +26 -15
  3. package/dist/bot/commands/definitions.js +7 -4
  4. package/dist/bot/commands/help.js +7 -1
  5. package/dist/bot/commands/new.js +4 -0
  6. package/dist/bot/commands/projects.js +18 -9
  7. package/dist/bot/commands/rename.js +49 -1
  8. package/dist/bot/commands/sessions.js +15 -2
  9. package/dist/bot/commands/stop.js +3 -5
  10. package/dist/bot/handlers/agent.js +12 -1
  11. package/dist/bot/handlers/context.js +15 -25
  12. package/dist/bot/handlers/inline-menu.js +119 -0
  13. package/dist/bot/handlers/model.js +15 -2
  14. package/dist/bot/handlers/permission.js +81 -12
  15. package/dist/bot/handlers/question.js +97 -9
  16. package/dist/bot/handlers/variant.js +12 -1
  17. package/dist/bot/index.js +92 -16
  18. package/dist/bot/middleware/interaction-guard.js +80 -0
  19. package/dist/bot/middleware/unknown-command.js +22 -0
  20. package/dist/bot/utils/commands.js +21 -0
  21. package/dist/config.js +31 -0
  22. package/dist/i18n/en.js +23 -4
  23. package/dist/i18n/ru.js +23 -4
  24. package/dist/interaction/cleanup.js +24 -0
  25. package/dist/interaction/guard.js +87 -0
  26. package/dist/interaction/manager.js +106 -0
  27. package/dist/interaction/types.js +1 -0
  28. package/dist/permission/manager.js +60 -38
  29. package/dist/pinned/manager.js +7 -5
  30. package/dist/question/manager.js +33 -0
  31. package/dist/rename/manager.js +3 -0
  32. package/dist/settings/manager.js +6 -1
  33. package/dist/summary/aggregator.js +87 -6
  34. package/dist/summary/formatter.js +91 -15
  35. package/dist/summary/tool-message-batcher.js +182 -0
  36. package/package.json +1 -1
@@ -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,47 @@ 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
+ }
53
+ function syncPermissionInteractionState(metadata = {}) {
54
+ const pendingCount = permissionManager.getPendingCount();
55
+ if (pendingCount === 0) {
56
+ clearPermissionInteraction("permission_no_pending_requests");
57
+ return;
58
+ }
59
+ const nextMetadata = {
60
+ pendingCount,
61
+ ...metadata,
62
+ };
63
+ const state = interactionManager.getSnapshot();
64
+ if (state?.kind === "permission") {
65
+ interactionManager.transition({
66
+ expectedInput: "callback",
67
+ metadata: nextMetadata,
68
+ });
69
+ return;
70
+ }
71
+ interactionManager.start({
72
+ kind: "permission",
73
+ expectedInput: "callback",
74
+ metadata: nextMetadata,
75
+ });
76
+ }
77
+ function isPermissionReply(value) {
78
+ return value === "once" || value === "always" || value === "reject";
79
+ }
38
80
  /**
39
81
  * Handle permission callback from inline buttons
40
82
  */
@@ -47,13 +89,31 @@ export async function handlePermissionCallback(ctx) {
47
89
  }
48
90
  logger.debug(`[PermissionHandler] Received callback: ${data}`);
49
91
  if (!permissionManager.isActive()) {
92
+ clearPermissionInteraction("permission_inactive_callback");
93
+ await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
94
+ return true;
95
+ }
96
+ const callbackMessageId = getCallbackMessageId(ctx);
97
+ if (!permissionManager.isActiveMessage(callbackMessageId)) {
98
+ await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
99
+ return true;
100
+ }
101
+ const requestID = permissionManager.getRequestID(callbackMessageId);
102
+ if (!requestID) {
50
103
  await ctx.answerCallbackQuery({ text: t("permission.inactive_callback"), show_alert: true });
51
104
  return true;
52
105
  }
53
106
  const parts = data.split(":");
54
107
  const action = parts[1];
108
+ if (!isPermissionReply(action)) {
109
+ await ctx.answerCallbackQuery({
110
+ text: t("permission.processing_error_callback"),
111
+ show_alert: true,
112
+ });
113
+ return true;
114
+ }
55
115
  try {
56
- await handlePermissionReply(ctx, action);
116
+ await handlePermissionReply(ctx, action, requestID, callbackMessageId);
57
117
  }
58
118
  catch (err) {
59
119
  logger.error("[PermissionHandler] Error handling callback:", err);
@@ -67,13 +127,14 @@ export async function handlePermissionCallback(ctx) {
67
127
  /**
68
128
  * Handle permission reply (once/always/reject)
69
129
  */
70
- async function handlePermissionReply(ctx, reply) {
71
- const requestID = permissionManager.getRequestID();
130
+ async function handlePermissionReply(ctx, reply, requestID, callbackMessageId) {
72
131
  const currentProject = getCurrentProject();
73
132
  const currentSession = getCurrentSession();
74
133
  const chatId = ctx.chat?.id;
75
134
  const directory = currentSession?.directory ?? currentProject?.worktree;
76
- if (!requestID || !directory || !chatId) {
135
+ if (!directory || !chatId) {
136
+ permissionManager.clear();
137
+ clearPermissionInteraction("permission_invalid_runtime_context");
77
138
  await ctx.answerCallbackQuery({
78
139
  text: t("permission.no_active_request_callback"),
79
140
  show_alert: true,
@@ -111,14 +172,20 @@ async function handlePermissionReply(ctx, reply) {
111
172
  logger.info("[PermissionHandler] Permission reply sent successfully");
112
173
  },
113
174
  });
114
- permissionManager.clear();
175
+ permissionManager.removeByMessageId(callbackMessageId);
176
+ if (!permissionManager.isActive()) {
177
+ clearPermissionInteraction("permission_replied");
178
+ return;
179
+ }
180
+ syncPermissionInteractionState({
181
+ lastRepliedRequestID: requestID,
182
+ });
115
183
  }
116
184
  /**
117
185
  * Show permission request message with inline buttons
118
186
  */
119
187
  export async function showPermissionRequest(bot, chatId, request) {
120
188
  logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);
121
- permissionManager.startPermission(request);
122
189
  const text = formatPermissionText(request);
123
190
  const keyboard = buildPermissionKeyboard();
124
191
  try {
@@ -127,7 +194,11 @@ export async function showPermissionRequest(bot, chatId, request) {
127
194
  parse_mode: "Markdown",
128
195
  });
129
196
  logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
130
- permissionManager.setMessageId(message.message_id);
197
+ permissionManager.startPermission(request, message.message_id);
198
+ syncPermissionInteractionState({
199
+ requestID: request.id,
200
+ messageId: message.message_id,
201
+ });
131
202
  summaryAggregator.stopTypingIndicator();
132
203
  }
133
204
  catch (err) {
@@ -158,10 +229,8 @@ function formatPermissionText(request) {
158
229
  */
159
230
  function buildPermissionKeyboard() {
160
231
  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");
232
+ keyboard.text(t("permission.button.allow"), "permission:once").row();
233
+ keyboard.text(t("permission.button.always"), "permission:always").row();
234
+ keyboard.text(t("permission.button.reject"), "permission:reject");
166
235
  return keyboard;
167
236
  }
@@ -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);