@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.
- package/.env.example +11 -0
- package/README.md +26 -15
- package/dist/bot/commands/definitions.js +7 -4
- package/dist/bot/commands/help.js +7 -1
- package/dist/bot/commands/new.js +4 -0
- package/dist/bot/commands/projects.js +18 -9
- package/dist/bot/commands/rename.js +49 -1
- package/dist/bot/commands/sessions.js +15 -2
- package/dist/bot/commands/stop.js +3 -5
- package/dist/bot/handlers/agent.js +12 -1
- package/dist/bot/handlers/context.js +15 -25
- package/dist/bot/handlers/inline-menu.js +119 -0
- package/dist/bot/handlers/model.js +15 -2
- package/dist/bot/handlers/permission.js +81 -12
- package/dist/bot/handlers/question.js +97 -9
- package/dist/bot/handlers/variant.js +12 -1
- package/dist/bot/index.js +92 -16
- package/dist/bot/middleware/interaction-guard.js +80 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/config.js +31 -0
- package/dist/i18n/en.js +23 -4
- package/dist/i18n/ru.js +23 -4
- package/dist/interaction/cleanup.js +24 -0
- package/dist/interaction/guard.js +87 -0
- package/dist/interaction/manager.js +106 -0
- package/dist/interaction/types.js +1 -0
- package/dist/permission/manager.js +60 -38
- package/dist/pinned/manager.js +7 -5
- package/dist/question/manager.js +33 -0
- package/dist/rename/manager.js +3 -0
- package/dist/settings/manager.js +6 -1
- package/dist/summary/aggregator.js +87 -6
- package/dist/summary/formatter.js +91 -15
- package/dist/summary/tool-message-batcher.js +182 -0
- 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
|
|
118
|
-
|
|
|
119
|
-
| `TELEGRAM_BOT_TOKEN`
|
|
120
|
-
| `TELEGRAM_ALLOWED_USER_ID`
|
|
121
|
-
| `TELEGRAM_PROXY_URL`
|
|
122
|
-
| `OPENCODE_API_URL`
|
|
123
|
-
| `OPENCODE_SERVER_USERNAME`
|
|
124
|
-
| `OPENCODE_SERVER_PASSWORD`
|
|
125
|
-
| `OPENCODE_MODEL_PROVIDER`
|
|
126
|
-
| `OPENCODE_MODEL_ID`
|
|
127
|
-
| `BOT_LOCALE`
|
|
128
|
-
| `SESSIONS_LIST_LIMIT`
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
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
|
|
20
|
-
command,
|
|
21
|
-
|
|
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(
|
|
9
|
+
await ctx.reply(formatHelpText());
|
|
4
10
|
}
|
package/dist/bot/commands/new.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
43
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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")
|
|
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
|
|
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
|
+
}
|