@grinev/opencode-telegram-bot 0.10.0 → 0.11.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/README.md +17 -9
- package/dist/agent/manager.js +10 -14
- package/dist/bot/commands/commands.js +379 -0
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/projects.js +102 -18
- package/dist/bot/commands/status.js +20 -1
- package/dist/bot/handlers/agent.js +20 -10
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/index.js +27 -6
- package/dist/bot/message-patterns.js +2 -1
- package/dist/bot/utils/file-download.js +16 -0
- package/dist/i18n/de.js +25 -0
- package/dist/i18n/en.js +25 -0
- package/dist/i18n/es.js +25 -0
- package/dist/i18n/ru.js +25 -0
- package/dist/i18n/zh.js +25 -0
- package/dist/model/types.js +4 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,9 +11,9 @@ Run AI coding tasks, monitor progress, switch models, and manage sessions from y
|
|
|
11
11
|
|
|
12
12
|
No open ports, no exposed APIs. The bot communicates with your local OpenCode server and the Telegram Bot API only.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Platforms: macOS, Windows, Linux
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Languages: English (`en`), Deutsch (`de`), Español (`es`), Русский (`ru`), 简体中文 (`zh`)
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
19
19
|
<img src="assets/screencast.gif" width="45%" alt="OpenCode Telegram Bot screencast" />
|
|
@@ -26,11 +26,12 @@ Quick start: `npx @grinev/opencode-telegram-bot`
|
|
|
26
26
|
- **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time
|
|
27
27
|
- **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
|
|
28
28
|
- **Agent modes** — switch between Plan and Build modes on the fly
|
|
29
|
+
- **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
|
|
29
30
|
- **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
|
|
30
31
|
- **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
|
|
32
|
+
- **File attachments** — send images, PDF documents, and any text-based files to OpenCode (code, logs, configs etc.)
|
|
31
33
|
- **Context control** — compact context when it gets too large, right from the chat
|
|
32
34
|
- **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions
|
|
33
|
-
- **Configurable reply formatting** — assistant replies use Telegram MarkdownV2 by default, with optional raw mode (`MESSAGE_FORMAT_MODE=markdown|raw`)
|
|
34
35
|
- **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
|
|
35
36
|
- **Localization** — UI localization is supported for multiple languages (`BOT_LOCALE`)
|
|
36
37
|
|
|
@@ -68,6 +69,8 @@ The fastest way — run directly with `npx`:
|
|
|
68
69
|
npx @grinev/opencode-telegram-bot
|
|
69
70
|
```
|
|
70
71
|
|
|
72
|
+
> Quick start is for npm usage. You do not need to clone this repository. If you run this command from the source directory (repository root), it may fail with `opencode-telegram: not found`. To run from sources, use the [Development](#development) section.
|
|
73
|
+
|
|
71
74
|
On first launch, an interactive wizard will guide you through the configuration — it asks for interface language first, then your bot token, user ID, and OpenCode API URL. After that, you're ready to go. Open your bot in Telegram and start sending tasks.
|
|
72
75
|
|
|
73
76
|
#### Alternative: Global Install
|
|
@@ -85,11 +88,11 @@ opencode-telegram config
|
|
|
85
88
|
|
|
86
89
|
## Supported Platforms
|
|
87
90
|
|
|
88
|
-
| Platform | Status
|
|
89
|
-
| -------- |
|
|
90
|
-
| macOS | Fully supported
|
|
91
|
-
| Windows | Fully supported
|
|
92
|
-
| Linux |
|
|
91
|
+
| Platform | Status |
|
|
92
|
+
| -------- | -------------------------------------------- |
|
|
93
|
+
| macOS | Fully supported |
|
|
94
|
+
| Windows | Fully supported |
|
|
95
|
+
| Linux | Fully supported (tested on Ubuntu 24.04 LTS) |
|
|
93
96
|
|
|
94
97
|
## Bot Commands
|
|
95
98
|
|
|
@@ -101,6 +104,7 @@ opencode-telegram config
|
|
|
101
104
|
| `/sessions` | Browse and switch between recent sessions |
|
|
102
105
|
| `/projects` | Switch between OpenCode projects |
|
|
103
106
|
| `/rename` | Rename the current session |
|
|
107
|
+
| `/commands` | Browse and run custom commands |
|
|
104
108
|
| `/opencode_start` | Start the OpenCode server remotely |
|
|
105
109
|
| `/opencode_stop` | Stop the OpenCode server remotely |
|
|
106
110
|
| `/help` | Show available commands |
|
|
@@ -137,7 +141,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
137
141
|
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
|
|
138
142
|
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `ru`, `zh`) | No | `en` |
|
|
139
143
|
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
|
|
140
|
-
| `PROJECTS_LIST_LIMIT` |
|
|
144
|
+
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
|
|
141
145
|
| `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
|
|
142
146
|
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
|
|
143
147
|
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
|
|
@@ -250,6 +254,10 @@ npm run dev
|
|
|
250
254
|
|
|
251
255
|
Please follow commit and release note conventions in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
252
256
|
|
|
257
|
+
## Community
|
|
258
|
+
|
|
259
|
+
Have questions, want to share your experience using the bot, or have an idea for a feature? Join the conversation in [GitHub Discussions](https://github.com/grinev/opencode-telegram-bot/discussions).
|
|
260
|
+
|
|
253
261
|
## License
|
|
254
262
|
|
|
255
263
|
[MIT](LICENSE) © Ruslan Grinev
|
package/dist/agent/manager.js
CHANGED
|
@@ -8,15 +8,9 @@ import { logger } from "../utils/logger.js";
|
|
|
8
8
|
* @returns Array of available agents (filtered by mode and hidden flag)
|
|
9
9
|
*/
|
|
10
10
|
export async function getAvailableAgents() {
|
|
11
|
-
const project = getCurrentProject();
|
|
12
|
-
if (!project) {
|
|
13
|
-
logger.warn("[AgentManager] Cannot get agents: no project selected");
|
|
14
|
-
return [];
|
|
15
|
-
}
|
|
16
11
|
try {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
});
|
|
12
|
+
const project = getCurrentProject();
|
|
13
|
+
const { data: agents, error } = await opencodeClient.app.agents(project ? { directory: project.worktree } : undefined);
|
|
20
14
|
if (error) {
|
|
21
15
|
logger.error("[AgentManager] Failed to fetch agents:", error);
|
|
22
16
|
return [];
|
|
@@ -34,9 +28,11 @@ export async function getAvailableAgents() {
|
|
|
34
28
|
return [];
|
|
35
29
|
}
|
|
36
30
|
}
|
|
31
|
+
const DEFAULT_AGENT = "build";
|
|
37
32
|
/**
|
|
38
|
-
* Get current agent from last session message or settings
|
|
39
|
-
*
|
|
33
|
+
* Get current agent from last session message or settings.
|
|
34
|
+
* Falls back to "build" if nothing is stored.
|
|
35
|
+
* @returns Current agent name
|
|
40
36
|
*/
|
|
41
37
|
export async function fetchCurrentAgent() {
|
|
42
38
|
const storedAgent = getCurrentAgent();
|
|
@@ -44,7 +40,7 @@ export async function fetchCurrentAgent() {
|
|
|
44
40
|
const project = getCurrentProject();
|
|
45
41
|
if (!session || !project) {
|
|
46
42
|
// No active session, return stored agent from settings
|
|
47
|
-
return storedAgent;
|
|
43
|
+
return storedAgent ?? DEFAULT_AGENT;
|
|
48
44
|
}
|
|
49
45
|
try {
|
|
50
46
|
const { data: messages, error } = await opencodeClient.session.messages({
|
|
@@ -54,7 +50,7 @@ export async function fetchCurrentAgent() {
|
|
|
54
50
|
});
|
|
55
51
|
if (error || !messages || messages.length === 0) {
|
|
56
52
|
logger.debug("[AgentManager] No messages found, using stored agent");
|
|
57
|
-
return storedAgent;
|
|
53
|
+
return storedAgent ?? DEFAULT_AGENT;
|
|
58
54
|
}
|
|
59
55
|
const lastAgent = messages[0].info.agent;
|
|
60
56
|
logger.debug(`[AgentManager] Current agent from session: ${lastAgent}`);
|
|
@@ -68,11 +64,11 @@ export async function fetchCurrentAgent() {
|
|
|
68
64
|
if (lastAgent && lastAgent !== storedAgent) {
|
|
69
65
|
setCurrentAgent(lastAgent);
|
|
70
66
|
}
|
|
71
|
-
return lastAgent || storedAgent;
|
|
67
|
+
return lastAgent || storedAgent || DEFAULT_AGENT;
|
|
72
68
|
}
|
|
73
69
|
catch (err) {
|
|
74
70
|
logger.error("[AgentManager] Error fetching current agent:", err);
|
|
75
|
-
return storedAgent;
|
|
71
|
+
return storedAgent ?? DEFAULT_AGENT;
|
|
76
72
|
}
|
|
77
73
|
}
|
|
78
74
|
/**
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
3
|
+
import { getCurrentProject } from "../../settings/manager.js";
|
|
4
|
+
import { clearSession, getCurrentSession, setCurrentSession, } from "../../session/manager.js";
|
|
5
|
+
import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
|
|
6
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
7
|
+
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
8
|
+
import { getStoredAgent } from "../../agent/manager.js";
|
|
9
|
+
import { getStoredModel } from "../../model/manager.js";
|
|
10
|
+
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
11
|
+
import { logger } from "../../utils/logger.js";
|
|
12
|
+
import { t } from "../../i18n/index.js";
|
|
13
|
+
const COMMANDS_CALLBACK_PREFIX = "commands:";
|
|
14
|
+
const COMMANDS_CALLBACK_SELECT_PREFIX = `${COMMANDS_CALLBACK_PREFIX}select:`;
|
|
15
|
+
const COMMANDS_CALLBACK_CANCEL = `${COMMANDS_CALLBACK_PREFIX}cancel`;
|
|
16
|
+
const COMMANDS_CALLBACK_EXECUTE = `${COMMANDS_CALLBACK_PREFIX}execute`;
|
|
17
|
+
const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
|
|
18
|
+
function formatExecutingCommandMessage(commandName, args) {
|
|
19
|
+
const commandText = `/${commandName}`;
|
|
20
|
+
const argsSuffix = args ? ` ${args}` : "";
|
|
21
|
+
return `${t("commands.executing_prefix")}\n${commandText}${argsSuffix}`;
|
|
22
|
+
}
|
|
23
|
+
function normalizeDirectoryForCommandApi(directory) {
|
|
24
|
+
return directory.replace(/\\/g, "/");
|
|
25
|
+
}
|
|
26
|
+
function getCallbackMessageId(ctx) {
|
|
27
|
+
const message = ctx.callbackQuery?.message;
|
|
28
|
+
if (!message || !("message_id" in message)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const messageId = message.message_id;
|
|
32
|
+
return typeof messageId === "number" ? messageId : null;
|
|
33
|
+
}
|
|
34
|
+
function formatCommandButtonLabel(command) {
|
|
35
|
+
const description = command.description?.trim() || t("commands.no_description");
|
|
36
|
+
const rawLabel = `/${command.name} - ${description}`;
|
|
37
|
+
if (rawLabel.length <= MAX_INLINE_BUTTON_LABEL_LENGTH) {
|
|
38
|
+
return rawLabel;
|
|
39
|
+
}
|
|
40
|
+
return `${rawLabel.slice(0, MAX_INLINE_BUTTON_LABEL_LENGTH - 3)}...`;
|
|
41
|
+
}
|
|
42
|
+
function buildCommandsListKeyboard(commands) {
|
|
43
|
+
const keyboard = new InlineKeyboard();
|
|
44
|
+
commands.forEach((command, index) => {
|
|
45
|
+
keyboard
|
|
46
|
+
.text(formatCommandButtonLabel(command), `${COMMANDS_CALLBACK_SELECT_PREFIX}${index}`)
|
|
47
|
+
.row();
|
|
48
|
+
});
|
|
49
|
+
keyboard.text(t("commands.button.cancel"), COMMANDS_CALLBACK_CANCEL);
|
|
50
|
+
return keyboard;
|
|
51
|
+
}
|
|
52
|
+
function buildCommandsConfirmKeyboard() {
|
|
53
|
+
return new InlineKeyboard()
|
|
54
|
+
.text(t("commands.button.execute"), COMMANDS_CALLBACK_EXECUTE)
|
|
55
|
+
.text(t("commands.button.cancel"), COMMANDS_CALLBACK_CANCEL);
|
|
56
|
+
}
|
|
57
|
+
function parseCommandItems(value) {
|
|
58
|
+
if (!Array.isArray(value)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const commands = [];
|
|
62
|
+
for (const item of value) {
|
|
63
|
+
if (!item || typeof item !== "object") {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const commandName = item.name;
|
|
67
|
+
if (typeof commandName !== "string" || !commandName.trim()) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const description = item.description;
|
|
71
|
+
commands.push({
|
|
72
|
+
name: commandName,
|
|
73
|
+
description: typeof description === "string" ? description : undefined,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return commands;
|
|
77
|
+
}
|
|
78
|
+
function parseCommandsMetadata(state) {
|
|
79
|
+
if (!state || state.kind !== "custom") {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const flow = state.metadata.flow;
|
|
83
|
+
const stage = state.metadata.stage;
|
|
84
|
+
const messageId = state.metadata.messageId;
|
|
85
|
+
const projectDirectory = state.metadata.projectDirectory;
|
|
86
|
+
if (flow !== "commands" ||
|
|
87
|
+
typeof messageId !== "number" ||
|
|
88
|
+
typeof projectDirectory !== "string") {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (stage === "list") {
|
|
92
|
+
const commands = parseCommandItems(state.metadata.commands);
|
|
93
|
+
if (!commands) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
flow,
|
|
98
|
+
stage,
|
|
99
|
+
messageId,
|
|
100
|
+
projectDirectory,
|
|
101
|
+
commands,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (stage === "confirm") {
|
|
105
|
+
const commandName = state.metadata.commandName;
|
|
106
|
+
if (typeof commandName !== "string" || !commandName.trim()) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
flow,
|
|
111
|
+
stage,
|
|
112
|
+
messageId,
|
|
113
|
+
projectDirectory,
|
|
114
|
+
commandName,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
function clearCommandsInteraction(reason) {
|
|
120
|
+
const metadata = parseCommandsMetadata(interactionManager.getSnapshot());
|
|
121
|
+
if (metadata) {
|
|
122
|
+
interactionManager.clear(reason);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function getCommandList(projectDirectory) {
|
|
126
|
+
const { data, error } = await opencodeClient.command.list({
|
|
127
|
+
directory: normalizeDirectoryForCommandApi(projectDirectory),
|
|
128
|
+
});
|
|
129
|
+
if (error || !data) {
|
|
130
|
+
throw error || new Error("No command data received");
|
|
131
|
+
}
|
|
132
|
+
return data
|
|
133
|
+
.filter((command) => typeof command.name === "string" && command.name.trim().length > 0)
|
|
134
|
+
.map((command) => ({
|
|
135
|
+
name: command.name,
|
|
136
|
+
description: command.description,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
function parseSelectIndex(data) {
|
|
140
|
+
if (!data.startsWith(COMMANDS_CALLBACK_SELECT_PREFIX)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const rawIndex = data.slice(COMMANDS_CALLBACK_SELECT_PREFIX.length);
|
|
144
|
+
const index = Number(rawIndex);
|
|
145
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return index;
|
|
149
|
+
}
|
|
150
|
+
async function isSessionBusy(sessionId, directory) {
|
|
151
|
+
try {
|
|
152
|
+
const { data, error } = await opencodeClient.session.status({ directory });
|
|
153
|
+
if (error || !data) {
|
|
154
|
+
logger.warn("[Commands] Failed to check session status before command:", error);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
const sessionStatus = data[sessionId];
|
|
158
|
+
if (!sessionStatus) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return sessionStatus.type === "busy";
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
logger.warn("[Commands] Error checking session status before command:", err);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function ensureSessionForProject(ctx, projectDirectory) {
|
|
169
|
+
let currentSession = getCurrentSession();
|
|
170
|
+
if (currentSession && currentSession.directory !== projectDirectory) {
|
|
171
|
+
logger.warn(`[Commands] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${projectDirectory}. Resetting session context.`);
|
|
172
|
+
clearSession();
|
|
173
|
+
summaryAggregator.clear();
|
|
174
|
+
await ctx.reply(t("bot.session_reset_project_mismatch"));
|
|
175
|
+
currentSession = null;
|
|
176
|
+
}
|
|
177
|
+
if (currentSession) {
|
|
178
|
+
return currentSession;
|
|
179
|
+
}
|
|
180
|
+
await ctx.reply(t("bot.creating_session"));
|
|
181
|
+
const { data: session, error } = await opencodeClient.session.create({
|
|
182
|
+
directory: projectDirectory,
|
|
183
|
+
});
|
|
184
|
+
if (error || !session) {
|
|
185
|
+
await ctx.reply(t("bot.create_session_error"));
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const sessionInfo = {
|
|
189
|
+
id: session.id,
|
|
190
|
+
title: session.title,
|
|
191
|
+
directory: projectDirectory,
|
|
192
|
+
};
|
|
193
|
+
setCurrentSession(sessionInfo);
|
|
194
|
+
await ingestSessionInfoForCache(session);
|
|
195
|
+
await ctx.reply(t("bot.session_created", { title: session.title }));
|
|
196
|
+
return sessionInfo;
|
|
197
|
+
}
|
|
198
|
+
async function executeCommand(ctx, deps, params) {
|
|
199
|
+
if (!ctx.chat) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const args = params.argumentsText.trim();
|
|
203
|
+
await ctx.reply(formatExecutingCommandMessage(params.commandName, args));
|
|
204
|
+
const session = await ensureSessionForProject(ctx, params.projectDirectory);
|
|
205
|
+
if (!session) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
await deps.ensureEventSubscription(session.directory);
|
|
209
|
+
summaryAggregator.setSession(session.id);
|
|
210
|
+
summaryAggregator.setBotAndChatId(deps.bot, ctx.chat.id);
|
|
211
|
+
const sessionIsBusy = await isSessionBusy(session.id, session.directory);
|
|
212
|
+
if (sessionIsBusy) {
|
|
213
|
+
await ctx.reply(t("bot.session_busy"));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const currentAgent = getStoredAgent();
|
|
217
|
+
const storedModel = getStoredModel();
|
|
218
|
+
const model = storedModel.providerID && storedModel.modelID
|
|
219
|
+
? `${storedModel.providerID}/${storedModel.modelID}`
|
|
220
|
+
: undefined;
|
|
221
|
+
safeBackgroundTask({
|
|
222
|
+
taskName: "session.command",
|
|
223
|
+
task: () => opencodeClient.session.command({
|
|
224
|
+
sessionID: session.id,
|
|
225
|
+
directory: session.directory,
|
|
226
|
+
command: params.commandName,
|
|
227
|
+
arguments: args,
|
|
228
|
+
agent: currentAgent,
|
|
229
|
+
model,
|
|
230
|
+
variant: storedModel.variant,
|
|
231
|
+
}),
|
|
232
|
+
onSuccess: ({ error }) => {
|
|
233
|
+
if (error) {
|
|
234
|
+
logger.error("[Commands] OpenCode API returned an error for session.command", {
|
|
235
|
+
sessionId: session.id,
|
|
236
|
+
command: params.commandName,
|
|
237
|
+
args,
|
|
238
|
+
});
|
|
239
|
+
logger.error("[Commands] session.command error details:", error);
|
|
240
|
+
void ctx.api.sendMessage(ctx.chat.id, t("commands.execute_error")).catch(() => { });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
logger.info(`[Commands] session.command completed: session=${session.id}, command=/${params.commandName}`);
|
|
244
|
+
},
|
|
245
|
+
onError: (error) => {
|
|
246
|
+
logger.error("[Commands] session.command background task failed", {
|
|
247
|
+
sessionId: session.id,
|
|
248
|
+
command: params.commandName,
|
|
249
|
+
args,
|
|
250
|
+
});
|
|
251
|
+
logger.error("[Commands] session.command background failure details:", error);
|
|
252
|
+
void ctx.api.sendMessage(ctx.chat.id, t("commands.execute_error")).catch(() => { });
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
export async function commandsCommand(ctx) {
|
|
257
|
+
try {
|
|
258
|
+
const currentProject = getCurrentProject();
|
|
259
|
+
if (!currentProject) {
|
|
260
|
+
await ctx.reply(t("bot.project_not_selected"));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const commands = await getCommandList(currentProject.worktree);
|
|
264
|
+
if (commands.length === 0) {
|
|
265
|
+
await ctx.reply(t("commands.empty"));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const keyboard = buildCommandsListKeyboard(commands);
|
|
269
|
+
const message = await ctx.reply(t("commands.select"), {
|
|
270
|
+
reply_markup: keyboard,
|
|
271
|
+
});
|
|
272
|
+
interactionManager.start({
|
|
273
|
+
kind: "custom",
|
|
274
|
+
expectedInput: "callback",
|
|
275
|
+
metadata: {
|
|
276
|
+
flow: "commands",
|
|
277
|
+
stage: "list",
|
|
278
|
+
messageId: message.message_id,
|
|
279
|
+
projectDirectory: currentProject.worktree,
|
|
280
|
+
commands,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
logger.error("[Commands] Error fetching commands list:", error);
|
|
286
|
+
await ctx.reply(t("commands.fetch_error"));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
export async function handleCommandsCallback(ctx, deps) {
|
|
290
|
+
const data = ctx.callbackQuery?.data;
|
|
291
|
+
if (!data || !data.startsWith(COMMANDS_CALLBACK_PREFIX)) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
const metadata = parseCommandsMetadata(interactionManager.getSnapshot());
|
|
295
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
296
|
+
if (!metadata || callbackMessageId === null || metadata.messageId !== callbackMessageId) {
|
|
297
|
+
await ctx.answerCallbackQuery({ text: t("commands.inactive_callback"), show_alert: true });
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
if (data === COMMANDS_CALLBACK_CANCEL) {
|
|
302
|
+
clearCommandsInteraction("commands_cancelled");
|
|
303
|
+
await ctx.answerCallbackQuery({ text: t("commands.cancelled_callback") });
|
|
304
|
+
await ctx.deleteMessage().catch(() => { });
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
if (data === COMMANDS_CALLBACK_EXECUTE) {
|
|
308
|
+
if (metadata.stage !== "confirm") {
|
|
309
|
+
await ctx.answerCallbackQuery({ text: t("commands.inactive_callback"), show_alert: true });
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
clearCommandsInteraction("commands_execute_clicked");
|
|
313
|
+
await ctx.answerCallbackQuery({ text: t("commands.execute_callback") });
|
|
314
|
+
await ctx.deleteMessage().catch(() => { });
|
|
315
|
+
await executeCommand(ctx, deps, {
|
|
316
|
+
projectDirectory: metadata.projectDirectory,
|
|
317
|
+
commandName: metadata.commandName,
|
|
318
|
+
argumentsText: "",
|
|
319
|
+
});
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
const commandIndex = parseSelectIndex(data);
|
|
323
|
+
if (commandIndex === null || metadata.stage !== "list") {
|
|
324
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
const selectedCommand = metadata.commands[commandIndex];
|
|
328
|
+
if (!selectedCommand) {
|
|
329
|
+
await ctx.answerCallbackQuery({ text: t("commands.inactive_callback"), show_alert: true });
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
await ctx.answerCallbackQuery();
|
|
333
|
+
await ctx.editMessageText(t("commands.confirm", { command: `/${selectedCommand.name}` }), {
|
|
334
|
+
reply_markup: buildCommandsConfirmKeyboard(),
|
|
335
|
+
});
|
|
336
|
+
interactionManager.transition({
|
|
337
|
+
expectedInput: "mixed",
|
|
338
|
+
metadata: {
|
|
339
|
+
flow: "commands",
|
|
340
|
+
stage: "confirm",
|
|
341
|
+
messageId: metadata.messageId,
|
|
342
|
+
projectDirectory: metadata.projectDirectory,
|
|
343
|
+
commandName: selectedCommand.name,
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
logger.error("[Commands] Error handling command callback:", error);
|
|
350
|
+
clearCommandsInteraction("commands_callback_error");
|
|
351
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
export async function handleCommandTextArguments(ctx, deps) {
|
|
356
|
+
const text = ctx.message?.text;
|
|
357
|
+
if (!text || text.startsWith("/")) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
const metadata = parseCommandsMetadata(interactionManager.getSnapshot());
|
|
361
|
+
if (!metadata || metadata.stage !== "confirm") {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
const argumentsText = text.trim();
|
|
365
|
+
if (!argumentsText) {
|
|
366
|
+
await ctx.reply(t("commands.arguments_empty"));
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
clearCommandsInteraction("commands_arguments_submitted");
|
|
370
|
+
if (ctx.chat) {
|
|
371
|
+
await ctx.api.deleteMessage(ctx.chat.id, metadata.messageId).catch(() => { });
|
|
372
|
+
}
|
|
373
|
+
await executeCommand(ctx, deps, {
|
|
374
|
+
projectDirectory: metadata.projectDirectory,
|
|
375
|
+
commandName: metadata.commandName,
|
|
376
|
+
argumentsText,
|
|
377
|
+
});
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
@@ -10,6 +10,7 @@ const COMMAND_DEFINITIONS = [
|
|
|
10
10
|
{ command: "sessions", descriptionKey: "cmd.description.sessions" },
|
|
11
11
|
{ command: "projects", descriptionKey: "cmd.description.projects" },
|
|
12
12
|
{ command: "rename", descriptionKey: "cmd.description.rename" },
|
|
13
|
+
{ command: "commands", descriptionKey: "cmd.description.commands" },
|
|
13
14
|
{ command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
|
|
14
15
|
{ command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
|
|
15
16
|
{ command: "help", descriptionKey: "cmd.description.help" },
|
|
@@ -11,11 +11,12 @@ import { getStoredModel } from "../../model/manager.js";
|
|
|
11
11
|
import { formatVariantForButton } from "../../variant/manager.js";
|
|
12
12
|
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
13
13
|
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
14
|
-
import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
|
|
14
|
+
import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
|
|
15
15
|
import { logger } from "../../utils/logger.js";
|
|
16
16
|
import { t } from "../../i18n/index.js";
|
|
17
17
|
import { config } from "../../config.js";
|
|
18
18
|
const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
|
|
19
|
+
const PROJECT_PAGE_CALLBACK_PREFIX = "projects:page:";
|
|
19
20
|
function formatProjectButtonLabel(label, isActive) {
|
|
20
21
|
const prefix = isActive ? "✅ " : "";
|
|
21
22
|
const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
|
|
@@ -36,29 +37,84 @@ export function buildProjectButtonLabel(index, worktree) {
|
|
|
36
37
|
const folderName = getProjectFolderName(worktree);
|
|
37
38
|
return `${index + 1}. ${folderName} [${worktree}]`;
|
|
38
39
|
}
|
|
40
|
+
export function parseProjectPageCallback(data) {
|
|
41
|
+
if (!data.startsWith(PROJECT_PAGE_CALLBACK_PREFIX)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const rawPage = data.slice(PROJECT_PAGE_CALLBACK_PREFIX.length);
|
|
45
|
+
if (!/^\d+$/.test(rawPage)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return Number.parseInt(rawPage, 10);
|
|
49
|
+
}
|
|
50
|
+
export function calculateProjectsPaginationRange(totalProjects, page, pageSize) {
|
|
51
|
+
const safePageSize = Math.max(1, pageSize);
|
|
52
|
+
const totalPages = Math.max(1, Math.ceil(totalProjects / safePageSize));
|
|
53
|
+
const normalizedPage = Math.min(Math.max(0, page), totalPages - 1);
|
|
54
|
+
const startIndex = normalizedPage * safePageSize;
|
|
55
|
+
const endIndex = Math.min(startIndex + safePageSize, totalProjects);
|
|
56
|
+
return {
|
|
57
|
+
page: normalizedPage,
|
|
58
|
+
totalPages,
|
|
59
|
+
startIndex,
|
|
60
|
+
endIndex,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function buildProjectsMenuText(currentProjectName, page, totalPages) {
|
|
64
|
+
const baseText = currentProjectName
|
|
65
|
+
? t("projects.select_with_current", {
|
|
66
|
+
project: currentProjectName,
|
|
67
|
+
})
|
|
68
|
+
: t("projects.select");
|
|
69
|
+
if (totalPages <= 1) {
|
|
70
|
+
return baseText;
|
|
71
|
+
}
|
|
72
|
+
return `${baseText}\n\n${t("projects.page_indicator", {
|
|
73
|
+
current: String(page + 1),
|
|
74
|
+
total: String(totalPages),
|
|
75
|
+
})}`;
|
|
76
|
+
}
|
|
77
|
+
function buildProjectsKeyboard(projects, page) {
|
|
78
|
+
const keyboard = new InlineKeyboard();
|
|
79
|
+
const currentProject = getCurrentProject();
|
|
80
|
+
const pageSize = config.bot.projectsListLimit;
|
|
81
|
+
const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateProjectsPaginationRange(projects.length, page, pageSize);
|
|
82
|
+
projects.slice(startIndex, endIndex).forEach((project, index) => {
|
|
83
|
+
const isActive = currentProject &&
|
|
84
|
+
(project.id === currentProject.id || project.worktree === currentProject.worktree);
|
|
85
|
+
const label = buildProjectButtonLabel(startIndex + index, project.worktree);
|
|
86
|
+
const labelWithCheck = formatProjectButtonLabel(label, Boolean(isActive));
|
|
87
|
+
keyboard.text(labelWithCheck, `project:${project.id}`).row();
|
|
88
|
+
});
|
|
89
|
+
if (totalPages > 1) {
|
|
90
|
+
if (normalizedPage > 0) {
|
|
91
|
+
keyboard.text(t("projects.prev_page"), `${PROJECT_PAGE_CALLBACK_PREFIX}${normalizedPage - 1}`);
|
|
92
|
+
}
|
|
93
|
+
if (normalizedPage < totalPages - 1) {
|
|
94
|
+
keyboard.text(t("projects.next_page"), `${PROJECT_PAGE_CALLBACK_PREFIX}${normalizedPage + 1}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return keyboard;
|
|
98
|
+
}
|
|
99
|
+
function buildProjectsMenuView(projects, page) {
|
|
100
|
+
const currentProject = getCurrentProject();
|
|
101
|
+
const pageSize = config.bot.projectsListLimit;
|
|
102
|
+
const { page: normalizedPage, totalPages } = calculateProjectsPaginationRange(projects.length, page, pageSize);
|
|
103
|
+
const currentProjectName = currentProject?.name || currentProject?.worktree || null;
|
|
104
|
+
return {
|
|
105
|
+
text: buildProjectsMenuText(currentProjectName, normalizedPage, totalPages),
|
|
106
|
+
keyboard: buildProjectsKeyboard(projects, normalizedPage),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
39
109
|
export async function projectsCommand(ctx) {
|
|
40
110
|
try {
|
|
41
111
|
await syncSessionDirectoryCache();
|
|
42
112
|
const projects = await getProjects();
|
|
43
|
-
|
|
44
|
-
if (projectsToShow.length === 0) {
|
|
113
|
+
if (projects.length === 0) {
|
|
45
114
|
await ctx.reply(t("projects.empty"));
|
|
46
115
|
return;
|
|
47
116
|
}
|
|
48
|
-
const keyboard =
|
|
49
|
-
const currentProject = getCurrentProject();
|
|
50
|
-
projectsToShow.forEach((project, index) => {
|
|
51
|
-
const isActive = currentProject &&
|
|
52
|
-
(project.id === currentProject.id || project.worktree === currentProject.worktree);
|
|
53
|
-
const label = buildProjectButtonLabel(index, project.worktree);
|
|
54
|
-
const labelWithCheck = formatProjectButtonLabel(label, Boolean(isActive));
|
|
55
|
-
keyboard.text(labelWithCheck, `project:${project.id}`).row();
|
|
56
|
-
});
|
|
57
|
-
const text = currentProject
|
|
58
|
-
? t("projects.select_with_current", {
|
|
59
|
-
project: currentProject.name || currentProject.worktree,
|
|
60
|
-
})
|
|
61
|
-
: t("projects.select");
|
|
117
|
+
const { text, keyboard } = buildProjectsMenuView(projects, 0);
|
|
62
118
|
await replyWithInlineMenu(ctx, {
|
|
63
119
|
menuKind: "project",
|
|
64
120
|
text,
|
|
@@ -72,7 +128,35 @@ export async function projectsCommand(ctx) {
|
|
|
72
128
|
}
|
|
73
129
|
export async function handleProjectSelect(ctx) {
|
|
74
130
|
const callbackQuery = ctx.callbackQuery;
|
|
75
|
-
if (!callbackQuery?.data
|
|
131
|
+
if (!callbackQuery?.data) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
const page = parseProjectPageCallback(callbackQuery.data);
|
|
135
|
+
if (page !== null) {
|
|
136
|
+
const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
|
|
137
|
+
if (!isActiveMenu) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const projects = await getProjects();
|
|
142
|
+
if (projects.length === 0) {
|
|
143
|
+
await ctx.answerCallbackQuery();
|
|
144
|
+
await ctx.reply(t("projects.empty"));
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
const { text, keyboard } = buildProjectsMenuView(projects, page);
|
|
148
|
+
await ctx.answerCallbackQuery();
|
|
149
|
+
await ctx.editMessageText(text, {
|
|
150
|
+
reply_markup: appendInlineMenuCancelButton(keyboard, "project"),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
logger.error("[Bot] Error switching projects page:", error);
|
|
155
|
+
await ctx.answerCallbackQuery({ text: t("projects.page_load_error") });
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
if (!callbackQuery.data.startsWith("project:")) {
|
|
76
160
|
return false;
|
|
77
161
|
}
|
|
78
162
|
const projectId = callbackQuery.data.replace("project:", "");
|