@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
package/.env.example CHANGED
@@ -39,6 +39,17 @@ OPENCODE_MODEL_ID=big-pickle
39
39
  # Bot locale: en or ru (default: en)
40
40
  # BOT_LOCALE=en
41
41
 
42
+ # Service message batching interval in seconds (thinking + tool calls, default: 5)
43
+ # Recommended: keep >=2 to reduce risk of hitting Telegram rate limits (about 1 message/sec)
44
+ # 0 = send immediately (can hit rate limits if there are many tool calls)
45
+ # SERVICE_MESSAGES_INTERVAL_SEC=5
46
+
47
+ # Hide thinking indicator messages (default: false)
48
+ # HIDE_THINKING_MESSAGES=false
49
+
50
+ # Hide tool call service messages (default: false)
51
+ # HIDE_TOOL_CALL_MESSAGES=false
52
+
42
53
  # Code File Settings (optional)
43
54
  # Maximum file size in KB to send as document (default: 100)
44
55
  # CODE_FILE_MAX_SIZE_KB=100
package/README.md CHANGED
@@ -100,7 +100,15 @@ opencode-telegram config
100
100
  | `/opencode_stop` | Stop the OpenCode server remotely |
101
101
  | `/help` | Show available commands |
102
102
 
103
- Any regular text message is sent as a prompt to the coding agent.
103
+ Any regular text message is sent as a prompt to the coding agent only when no blocking interaction is active.
104
+
105
+ ### Interaction Rules
106
+
107
+ - Only one interactive flow can be active at a time (inline menus, permission request, question flow, rename)
108
+ - While an interaction is active, the bot accepts only relevant input for that flow and blocks unrelated actions
109
+ - Allowed utility commands remain available during active interactions: `/help`, `/status`, `/stop`
110
+ - Unknown slash commands return an explicit fallback message instead of being silently ignored
111
+ - Interaction flows do not expire automatically and wait until explicit completion (`answer`, `cancel`, `/stop`, or reset/cleanup)
104
112
 
105
113
  > `/opencode_start` and `/opencode_stop` are intended as emergency commands — for example, if you need to restart a stuck server while away from your computer. Under normal usage, start `opencode serve` yourself before launching the bot.
106
114
 
@@ -114,20 +122,23 @@ When installed via npm, the configuration wizard handles the initial setup. The
114
122
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
115
123
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
116
124
 
117
- | Variable | Description | Required | Default |
118
- | -------------------------- | -------------------------------------------- | :------: | ----------------------- |
119
- | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
120
- | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
121
- | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
122
- | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
123
- | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
124
- | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
125
- | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
126
- | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
127
- | `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
128
- | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
129
- | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
130
- | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
125
+ | Variable | Description | Required | Default |
126
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ----------------------- |
127
+ | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
128
+ | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
129
+ | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
130
+ | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
131
+ | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
132
+ | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
133
+ | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
134
+ | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
135
+ | `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
136
+ | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
137
+ | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
138
+ | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
139
+ | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
140
+ | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
141
+ | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
131
142
 
132
143
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
133
144
 
@@ -16,7 +16,10 @@ const COMMAND_DEFINITIONS = [
16
16
  { command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
17
17
  { command: "help", descriptionKey: "cmd.description.help" },
18
18
  ];
19
- export const BOT_COMMANDS = COMMAND_DEFINITIONS.map(({ command, descriptionKey }) => ({
20
- command,
21
- description: t(descriptionKey),
22
- }));
19
+ export function getLocalizedBotCommands() {
20
+ return COMMAND_DEFINITIONS.map(({ command, descriptionKey }) => ({
21
+ command,
22
+ description: t(descriptionKey),
23
+ }));
24
+ }
25
+ export const BOT_COMMANDS = getLocalizedBotCommands();
@@ -1,4 +1,10 @@
1
1
  import { t } from "../../i18n/index.js";
2
+ import { getLocalizedBotCommands } from "./definitions.js";
3
+ function formatHelpText() {
4
+ const commands = getLocalizedBotCommands();
5
+ const lines = commands.map((item) => `/${item.command} - ${item.description}`);
6
+ return `📖 ${t("cmd.description.help")}\n\n${lines.join("\n")}`;
7
+ }
2
8
  export async function helpCommand(ctx) {
3
- await ctx.reply(t("help.text"), { parse_mode: "Markdown" });
9
+ await ctx.reply(formatHelpText());
4
10
  }
@@ -2,6 +2,8 @@ import { opencodeClient } from "../../opencode/client.js";
2
2
  import { setCurrentSession } from "../../session/manager.js";
3
3
  import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
+ import { summaryAggregator } from "../../summary/aggregator.js";
5
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
6
8
  import { keyboardManager } from "../../keyboard/manager.js";
7
9
  import { getStoredAgent } from "../../agent/manager.js";
@@ -31,6 +33,8 @@ export async function newCommand(ctx) {
31
33
  directory: currentProject.worktree,
32
34
  };
33
35
  setCurrentSession(sessionInfo);
36
+ summaryAggregator.clear();
37
+ clearAllInteractionState("session_created");
34
38
  await ingestSessionInfoForCache(session);
35
39
  // Initialize pinned message manager and create pinned message
36
40
  if (!pinnedMessageManager.isInitialized()) {
@@ -9,7 +9,9 @@ import { keyboardManager } from "../../keyboard/manager.js";
9
9
  import { getStoredAgent } from "../../agent/manager.js";
10
10
  import { getStoredModel } from "../../model/manager.js";
11
11
  import { formatVariantForButton } from "../../variant/manager.js";
12
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
12
13
  import { createMainKeyboard } from "../utils/keyboard.js";
14
+ import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
13
15
  import { logger } from "../../utils/logger.js";
14
16
  import { t } from "../../i18n/index.js";
15
17
  const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
@@ -42,15 +44,16 @@ export async function projectsCommand(ctx) {
42
44
  const labelWithCheck = formatProjectButtonLabel(label, Boolean(isActive));
43
45
  keyboard.text(labelWithCheck, `project:${project.id}`).row();
44
46
  });
45
- if (currentProject) {
46
- const projectName = currentProject.name || currentProject.worktree;
47
- await ctx.reply(t("projects.select_with_current", { project: projectName }), {
48
- reply_markup: keyboard,
49
- });
50
- }
51
- else {
52
- await ctx.reply(t("projects.select"), { reply_markup: keyboard });
53
- }
47
+ const text = currentProject
48
+ ? t("projects.select_with_current", {
49
+ project: currentProject.name || currentProject.worktree,
50
+ })
51
+ : t("projects.select");
52
+ await replyWithInlineMenu(ctx, {
53
+ menuKind: "project",
54
+ text,
55
+ keyboard,
56
+ });
54
57
  }
55
58
  catch (error) {
56
59
  logger.error("[Bot] Error fetching projects:", error);
@@ -63,6 +66,10 @@ export async function handleProjectSelect(ctx) {
63
66
  return false;
64
67
  }
65
68
  const projectId = callbackQuery.data.replace("project:", "");
69
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
70
+ if (!isActiveMenu) {
71
+ return true;
72
+ }
66
73
  try {
67
74
  const projects = await getProjects();
68
75
  const selectedProject = projects.find((p) => p.id === projectId);
@@ -73,6 +80,7 @@ export async function handleProjectSelect(ctx) {
73
80
  setCurrentProject(selectedProject);
74
81
  clearSession();
75
82
  summaryAggregator.clear();
83
+ clearAllInteractionState("project_switched");
76
84
  // Clear pinned message when switching projects
77
85
  try {
78
86
  await pinnedMessageManager.clear();
@@ -103,6 +111,7 @@ export async function handleProjectSelect(ctx) {
103
111
  await ctx.deleteMessage();
104
112
  }
105
113
  catch (error) {
114
+ clearAllInteractionState("project_select_error");
106
115
  logger.error("[Bot] Error selecting project:", error);
107
116
  await ctx.answerCallbackQuery();
108
117
  await ctx.reply(t("projects.select_error"));
@@ -2,9 +2,24 @@ import { InlineKeyboard } from "grammy";
2
2
  import { opencodeClient } from "../../opencode/client.js";
3
3
  import { getCurrentSession, setCurrentSession } from "../../session/manager.js";
4
4
  import { renameManager } from "../../rename/manager.js";
5
+ import { interactionManager } from "../../interaction/manager.js";
5
6
  import { pinnedMessageManager } from "../../pinned/manager.js";
6
7
  import { logger } from "../../utils/logger.js";
7
8
  import { t } from "../../i18n/index.js";
9
+ function getCallbackMessageId(ctx) {
10
+ const message = ctx.callbackQuery?.message;
11
+ if (!message || !("message_id" in message)) {
12
+ return null;
13
+ }
14
+ const messageId = message.message_id;
15
+ return typeof messageId === "number" ? messageId : null;
16
+ }
17
+ function clearRenameInteraction(reason) {
18
+ const state = interactionManager.getSnapshot();
19
+ if (state?.kind === "rename") {
20
+ interactionManager.clear(reason);
21
+ }
22
+ }
8
23
  export async function renameCommand(ctx) {
9
24
  try {
10
25
  const currentSession = getCurrentSession();
@@ -18,6 +33,14 @@ export async function renameCommand(ctx) {
18
33
  });
19
34
  renameManager.startWaiting(currentSession.id, currentSession.directory, currentSession.title);
20
35
  renameManager.setMessageId(message.message_id);
36
+ interactionManager.start({
37
+ kind: "rename",
38
+ expectedInput: "text",
39
+ metadata: {
40
+ sessionId: currentSession.id,
41
+ messageId: message.message_id,
42
+ },
43
+ });
21
44
  logger.info(`[RenameCommand] Waiting for new title for session: ${currentSession.id}`);
22
45
  }
23
46
  catch (error) {
@@ -31,9 +54,26 @@ export async function handleRenameCancel(ctx) {
31
54
  return false;
32
55
  }
33
56
  logger.debug("[RenameHandler] Cancel callback received");
57
+ if (!renameManager.isWaitingForName()) {
58
+ clearRenameInteraction("rename_cancel_inactive");
59
+ await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true });
60
+ return true;
61
+ }
62
+ const interactionState = interactionManager.getSnapshot();
63
+ if (interactionState?.kind !== "rename") {
64
+ renameManager.clear();
65
+ await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true });
66
+ return true;
67
+ }
68
+ const callbackMessageId = getCallbackMessageId(ctx);
69
+ if (!renameManager.isActiveMessage(callbackMessageId)) {
70
+ await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true });
71
+ return true;
72
+ }
34
73
  renameManager.clear();
74
+ clearRenameInteraction("rename_cancelled");
35
75
  await ctx.answerCallbackQuery();
36
- await ctx.editMessageText(t("rename.cancelled"));
76
+ await ctx.editMessageText(t("rename.cancelled")).catch(() => { });
37
77
  return true;
38
78
  }
39
79
  export async function handleRenameTextAnswer(ctx) {
@@ -47,9 +87,16 @@ export async function handleRenameTextAnswer(ctx) {
47
87
  if (text.startsWith("/")) {
48
88
  return false;
49
89
  }
90
+ const interactionState = interactionManager.getSnapshot();
91
+ if (interactionState?.kind !== "rename") {
92
+ renameManager.clear();
93
+ await ctx.reply(t("rename.inactive"));
94
+ return true;
95
+ }
50
96
  const sessionInfo = renameManager.getSessionInfo();
51
97
  if (!sessionInfo) {
52
98
  renameManager.clear();
99
+ clearRenameInteraction("rename_missing_session_info");
53
100
  return false;
54
101
  }
55
102
  const newTitle = text.trim();
@@ -87,5 +134,6 @@ export async function handleRenameTextAnswer(ctx) {
87
134
  await ctx.reply(t("rename.error"));
88
135
  }
89
136
  renameManager.clear();
137
+ clearRenameInteraction("rename_completed");
90
138
  return true;
91
139
  }
@@ -2,8 +2,11 @@ import { InlineKeyboard } from "grammy";
2
2
  import { opencodeClient } from "../../opencode/client.js";
3
3
  import { setCurrentSession } from "../../session/manager.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
+ import { summaryAggregator } from "../../summary/aggregator.js";
5
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
6
8
  import { keyboardManager } from "../../keyboard/manager.js";
9
+ import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
7
10
  import { logger } from "../../utils/logger.js";
8
11
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
9
12
  import { config } from "../../config.js";
@@ -39,8 +42,10 @@ export async function sessionsCommand(ctx) {
39
42
  const label = `${index + 1}. ${session.title} (${date})`;
40
43
  keyboard.text(label, `session:${session.id}`).row();
41
44
  });
42
- await ctx.reply(t("sessions.select"), {
43
- reply_markup: keyboard,
45
+ await replyWithInlineMenu(ctx, {
46
+ menuKind: "session",
47
+ text: t("sessions.select"),
48
+ keyboard,
44
49
  });
45
50
  }
46
51
  catch (error) {
@@ -54,9 +59,14 @@ export async function handleSessionSelect(ctx) {
54
59
  return false;
55
60
  }
56
61
  const sessionId = callbackQuery.data.replace("session:", "");
62
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "session");
63
+ if (!isActiveMenu) {
64
+ return true;
65
+ }
57
66
  try {
58
67
  const currentProject = getCurrentProject();
59
68
  if (!currentProject) {
69
+ clearAllInteractionState("session_select_project_missing");
60
70
  await ctx.answerCallbackQuery();
61
71
  await ctx.reply(t("sessions.select_project_first"));
62
72
  return true;
@@ -75,6 +85,8 @@ export async function handleSessionSelect(ctx) {
75
85
  directory: currentProject.worktree,
76
86
  };
77
87
  setCurrentSession(sessionInfo);
88
+ summaryAggregator.clear();
89
+ clearAllInteractionState("session_switched");
78
90
  await ctx.answerCallbackQuery();
79
91
  let loadingMessageId = null;
80
92
  if (ctx.chat) {
@@ -139,6 +151,7 @@ export async function handleSessionSelect(ctx) {
139
151
  await ctx.deleteMessage();
140
152
  }
141
153
  catch (error) {
154
+ clearAllInteractionState("session_select_error");
142
155
  logger.error("[Sessions] Error selecting session:", error);
143
156
  await ctx.answerCallbackQuery();
144
157
  await ctx.reply(t("sessions.select_error"));
@@ -1,8 +1,7 @@
1
1
  import { opencodeClient } from "../../opencode/client.js";
2
2
  import { stopEventListening } from "../../opencode/events.js";
3
3
  import { getCurrentSession } from "../../session/manager.js";
4
- import { permissionManager } from "../../permission/manager.js";
5
- import { questionManager } from "../../question/manager.js";
4
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
5
  import { summaryAggregator } from "../../summary/aggregator.js";
7
6
  import { logger } from "../../utils/logger.js";
8
7
  import { t } from "../../i18n/index.js";
@@ -10,8 +9,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
9
  function stopLocalStreaming() {
11
10
  stopEventListening();
12
11
  summaryAggregator.clear();
13
- questionManager.clear();
14
- permissionManager.clear();
12
+ clearAllInteractionState("stop_command");
15
13
  }
16
14
  async function pollSessionStatus(sessionId, directory, maxWaitMs = 5000) {
17
15
  const startedAt = Date.now();
@@ -43,12 +41,12 @@ async function pollSessionStatus(sessionId, directory, maxWaitMs = 5000) {
43
41
  }
44
42
  export async function stopCommand(ctx) {
45
43
  try {
44
+ stopLocalStreaming();
46
45
  const currentSession = getCurrentSession();
47
46
  if (!currentSession) {
48
47
  await ctx.reply(t("stop.no_active_session"));
49
48
  return;
50
49
  }
51
- stopLocalStreaming();
52
50
  const waitingMessage = await ctx.reply(t("stop.in_progress"));
53
51
  const controller = new AbortController();
54
52
  const timeoutId = setTimeout(() => controller.abort(), 5000);
@@ -7,6 +7,7 @@ import { logger } from "../../utils/logger.js";
7
7
  import { createMainKeyboard } from "../utils/keyboard.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 agent selection callback
@@ -18,6 +19,10 @@ export async function handleAgentSelect(ctx) {
18
19
  if (!callbackQuery?.data || !callbackQuery.data.startsWith("agent:")) {
19
20
  return false;
20
21
  }
22
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "agent");
23
+ if (!isActiveMenu) {
24
+ return true;
25
+ }
21
26
  logger.debug(`[AgentHandler] Received callback: ${callbackQuery.data}`);
22
27
  try {
23
28
  if (ctx.chat) {
@@ -45,6 +50,7 @@ export async function handleAgentSelect(ctx) {
45
50
  const variantName = state?.variantName ?? formatVariantForButton(currentModel.variant || "default");
46
51
  const keyboard = createMainKeyboard(agentName, currentModel, contextInfo ?? undefined, variantName);
47
52
  const displayName = getAgentDisplayName(agentName);
53
+ clearActiveInlineMenu("agent_selected");
48
54
  // Send confirmation message with updated keyboard
49
55
  await ctx.answerCallbackQuery({ text: t("agent.changed_callback", { name: displayName }) });
50
56
  await ctx.reply(t("agent.changed_message", { name: displayName }), {
@@ -55,6 +61,7 @@ export async function handleAgentSelect(ctx) {
55
61
  return true;
56
62
  }
57
63
  catch (err) {
64
+ clearActiveInlineMenu("agent_select_error");
58
65
  logger.error("[AgentHandler] Error handling agent select:", err);
59
66
  await ctx.answerCallbackQuery({ text: t("agent.change_error_callback") }).catch(() => { });
60
67
  return false;
@@ -93,5 +100,9 @@ export async function showAgentSelectionMenu(ctx) {
93
100
  const text = currentAgent
94
101
  ? t("agent.menu.current", { name: getAgentDisplayName(currentAgent) })
95
102
  : t("agent.menu.select");
96
- await ctx.reply(text, { reply_markup: keyboard });
103
+ await replyWithInlineMenu(ctx, {
104
+ menuKind: "agent",
105
+ text,
106
+ keyboard,
107
+ });
97
108
  }
@@ -2,16 +2,16 @@ import { InlineKeyboard } from "grammy";
2
2
  import { getCurrentSession } from "../../session/manager.js";
3
3
  import { opencodeClient } from "../../opencode/client.js";
4
4
  import { getStoredModel } from "../../model/manager.js";
5
+ import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
5
6
  import { logger } from "../../utils/logger.js";
6
7
  import { t } from "../../i18n/index.js";
7
8
  /**
8
9
  * Build inline keyboard with compact confirmation menu
9
- * @returns InlineKeyboard with "Yes" and "Cancel" buttons
10
+ * @returns InlineKeyboard with confirmation button
10
11
  */
11
12
  export function buildCompactConfirmationMenu() {
12
13
  const keyboard = new InlineKeyboard();
13
- keyboard.text(t("context.button.confirm"), "compact:confirm").row();
14
- keyboard.text(t("context.button.cancel"), "compact:cancel").row();
14
+ keyboard.text(t("context.button.confirm"), "compact:confirm");
15
15
  return keyboard;
16
16
  }
17
17
  /**
@@ -27,7 +27,11 @@ export async function handleContextButtonPress(ctx) {
27
27
  return;
28
28
  }
29
29
  const keyboard = buildCompactConfirmationMenu();
30
- await ctx.reply(t("context.confirm_text", { title: session.title }), { reply_markup: keyboard });
30
+ await replyWithInlineMenu(ctx, {
31
+ menuKind: "context",
32
+ text: t("context.confirm_text", { title: session.title }),
33
+ keyboard,
34
+ });
31
35
  }
32
36
  /**
33
37
  * Handle compact confirmation callback
@@ -39,10 +43,15 @@ export async function handleCompactConfirm(ctx) {
39
43
  if (!callbackQuery?.data || callbackQuery.data !== "compact:confirm") {
40
44
  return false;
41
45
  }
46
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "context");
47
+ if (!isActiveMenu) {
48
+ return true;
49
+ }
42
50
  logger.debug("[ContextHandler] Compact confirmed");
43
51
  try {
44
52
  const session = getCurrentSession();
45
53
  if (!session) {
54
+ clearActiveInlineMenu("context_session_missing");
46
55
  await ctx.answerCallbackQuery({ text: t("context.callback_session_not_found") });
47
56
  await ctx.reply(t("context.no_active_session"));
48
57
  await ctx.deleteMessage().catch(() => { });
@@ -50,6 +59,7 @@ export async function handleCompactConfirm(ctx) {
50
59
  }
51
60
  // Answer callback query and delete menu immediately
52
61
  await ctx.answerCallbackQuery({ text: t("context.callback_compacting") });
62
+ clearActiveInlineMenu("context_compact_confirmed");
53
63
  await ctx.deleteMessage().catch(() => { });
54
64
  // Send progress message
55
65
  const progressMessage = await ctx.reply(t("context.progress"));
@@ -80,6 +90,7 @@ export async function handleCompactConfirm(ctx) {
80
90
  return true;
81
91
  }
82
92
  catch (err) {
93
+ clearActiveInlineMenu("context_compact_error");
83
94
  logger.error("[ContextHandler] Compact exception:", err);
84
95
  await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
85
96
  await ctx.reply(t("context.error"));
@@ -87,24 +98,3 @@ export async function handleCompactConfirm(ctx) {
87
98
  return false;
88
99
  }
89
100
  }
90
- /**
91
- * Handle compact cancel callback
92
- * @param ctx grammY context
93
- */
94
- export async function handleCompactCancel(ctx) {
95
- const callbackQuery = ctx.callbackQuery;
96
- if (!callbackQuery?.data || callbackQuery.data !== "compact:cancel") {
97
- return false;
98
- }
99
- logger.debug("[ContextHandler] Compact cancelled");
100
- try {
101
- await ctx.answerCallbackQuery({ text: t("context.callback_cancelled") });
102
- // Delete the inline menu message
103
- await ctx.deleteMessage().catch(() => { });
104
- return true;
105
- }
106
- catch (err) {
107
- logger.error("[ContextHandler] Cancel error:", err);
108
- return false;
109
- }
110
- }
@@ -0,0 +1,119 @@
1
+ import { interactionManager } from "../../interaction/manager.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ import { t } from "../../i18n/index.js";
4
+ const INLINE_MENU_CANCEL_PREFIX = "inline:cancel:";
5
+ const LEGACY_CONTEXT_CANCEL_CALLBACK = "compact:cancel";
6
+ const INLINE_MENU_KINDS = ["project", "session", "model", "agent", "variant", "context"];
7
+ function isInlineMenuKind(value) {
8
+ return INLINE_MENU_KINDS.includes(value);
9
+ }
10
+ function getCallbackMessageId(ctx) {
11
+ const message = ctx.callbackQuery?.message;
12
+ if (!message || !("message_id" in message)) {
13
+ return null;
14
+ }
15
+ const messageId = message.message_id;
16
+ return typeof messageId === "number" ? messageId : null;
17
+ }
18
+ function getActiveInlineMenuMetadata(state) {
19
+ if (!state || state.kind !== "inline") {
20
+ return null;
21
+ }
22
+ const menuKind = state.metadata.menuKind;
23
+ const messageId = state.metadata.messageId;
24
+ if (typeof menuKind !== "string" || !isInlineMenuKind(menuKind)) {
25
+ return null;
26
+ }
27
+ if (typeof messageId !== "number") {
28
+ return null;
29
+ }
30
+ return {
31
+ menuKind,
32
+ messageId,
33
+ };
34
+ }
35
+ function getInlineCancelCallbackData(menuKind) {
36
+ return `${INLINE_MENU_CANCEL_PREFIX}${menuKind}`;
37
+ }
38
+ export function appendInlineMenuCancelButton(keyboard, menuKind) {
39
+ while (keyboard.inline_keyboard.length > 0 &&
40
+ keyboard.inline_keyboard[keyboard.inline_keyboard.length - 1].length === 0) {
41
+ keyboard.inline_keyboard.pop();
42
+ }
43
+ if (keyboard.inline_keyboard.length > 0) {
44
+ keyboard.row();
45
+ }
46
+ keyboard.text(t("inline.button.cancel"), getInlineCancelCallbackData(menuKind));
47
+ return keyboard;
48
+ }
49
+ export async function replyWithInlineMenu(ctx, options) {
50
+ const keyboard = appendInlineMenuCancelButton(options.keyboard, options.menuKind);
51
+ const replyOptions = {
52
+ reply_markup: keyboard,
53
+ };
54
+ if (options.parseMode) {
55
+ replyOptions.parse_mode = options.parseMode;
56
+ }
57
+ const message = await ctx.reply(options.text, replyOptions);
58
+ interactionManager.start({
59
+ kind: "inline",
60
+ expectedInput: "callback",
61
+ metadata: {
62
+ menuKind: options.menuKind,
63
+ messageId: message.message_id,
64
+ },
65
+ });
66
+ logger.debug(`[InlineMenu] Opened menu: kind=${options.menuKind}, messageId=${message.message_id}`);
67
+ return message.message_id;
68
+ }
69
+ export async function ensureActiveInlineMenu(ctx, menuKind) {
70
+ const activeMetadata = getActiveInlineMenuMetadata(interactionManager.getSnapshot());
71
+ const callbackMessageId = getCallbackMessageId(ctx);
72
+ const isActive = !!activeMetadata &&
73
+ callbackMessageId !== null &&
74
+ activeMetadata.menuKind === menuKind &&
75
+ activeMetadata.messageId === callbackMessageId;
76
+ if (isActive) {
77
+ return true;
78
+ }
79
+ logger.debug(`[InlineMenu] Stale callback ignored: expectedKind=${menuKind}, activeKind=${activeMetadata?.menuKind || "none"}, callbackMessageId=${callbackMessageId || "none"}, activeMessageId=${activeMetadata?.messageId || "none"}`);
80
+ await ctx
81
+ .answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true })
82
+ .catch(() => { });
83
+ return false;
84
+ }
85
+ export function clearActiveInlineMenu(reason) {
86
+ const state = interactionManager.getSnapshot();
87
+ if (state?.kind === "inline") {
88
+ interactionManager.clear(reason);
89
+ }
90
+ }
91
+ export async function handleInlineMenuCancel(ctx) {
92
+ const data = ctx.callbackQuery?.data;
93
+ if (!data) {
94
+ return false;
95
+ }
96
+ let menuKind = null;
97
+ if (data === LEGACY_CONTEXT_CANCEL_CALLBACK) {
98
+ menuKind = "context";
99
+ }
100
+ else if (data.startsWith(INLINE_MENU_CANCEL_PREFIX)) {
101
+ const rawKind = data.slice(INLINE_MENU_CANCEL_PREFIX.length);
102
+ if (!isInlineMenuKind(rawKind)) {
103
+ return false;
104
+ }
105
+ menuKind = rawKind;
106
+ }
107
+ else {
108
+ return false;
109
+ }
110
+ const isActive = await ensureActiveInlineMenu(ctx, menuKind);
111
+ if (!isActive) {
112
+ return true;
113
+ }
114
+ clearActiveInlineMenu(`inline_menu_cancel:${menuKind}`);
115
+ await ctx.answerCallbackQuery({ text: t("inline.cancelled_callback") }).catch(() => { });
116
+ await ctx.deleteMessage().catch(() => { });
117
+ logger.debug(`[InlineMenu] Menu cancelled: kind=${menuKind}`);
118
+ return true;
119
+ }