@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 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
- Languages: English (`en`), Deutsch (`de`), Español (`es`), Русский (`ru`), 简体中文 (`zh`)
14
+ Platforms: macOS, Windows, Linux
15
15
 
16
- Quick start: `npx @grinev/opencode-telegram-bot`
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 | Experimental should work, but has not been extensively tested. You may need additional steps such as granting execute permissions. |
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` | Max projects shown in `/projects` | No | `10` |
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
@@ -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 { data: agents, error } = await opencodeClient.app.agents({
18
- directory: project.worktree,
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
- * @returns Current agent name or undefined
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
- const projectsToShow = projects.slice(0, config.bot.projectsListLimit);
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 = new InlineKeyboard();
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 || !callbackQuery.data.startsWith("project:")) {
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:", "");