@grinev/opencode-telegram-bot 0.9.1 → 0.10.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
@@ -24,7 +24,7 @@ Quick start: `npx @grinev/opencode-telegram-bot`
24
24
  - **Remote coding** — send prompts to OpenCode from anywhere, receive complete results with code sent as files
25
25
  - **Session management** — create new sessions or continue existing ones, just like in the TUI
26
26
  - **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time
27
- - **Model switching** — pick any model from your OpenCode favorites directly in the chat
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
29
  - **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
30
30
  - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
@@ -100,8 +100,6 @@ opencode-telegram config
100
100
  | `/stop` | Abort the current task |
101
101
  | `/sessions` | Browse and switch between recent sessions |
102
102
  | `/projects` | Switch between OpenCode projects |
103
- | `/model` | Choose a model from your favorites |
104
- | `/agent` | Switch agent mode (Plan / Build) |
105
103
  | `/rename` | Rename the current session |
106
104
  | `/opencode_start` | Start the OpenCode server remotely |
107
105
  | `/opencode_stop` | Stop the OpenCode server remotely |
@@ -138,7 +136,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
138
136
  | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
139
137
  | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
140
138
  | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `ru`, `zh`) | No | `en` |
141
- | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
139
+ | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
142
140
  | `PROJECTS_LIST_LIMIT` | Max projects shown in `/projects` | No | `10` |
143
141
  | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
144
142
  | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
@@ -178,15 +176,14 @@ If STT variables are not set, voice/audio transcription is disabled and the bot
178
176
 
179
177
  ### Model Configuration
180
178
 
181
- The bot picks up your **favorite models** from OpenCode. To add a model to favorites:
179
+ The model picker uses OpenCode local model state (`favorite` + `recent`):
182
180
 
183
- 1. Open OpenCode TUI (`opencode`)
184
- 2. Go to model selection
185
- 3. Hover over the model you want and press **Ctrl+F** to add it to favorites
181
+ - Favorites are shown first, then recent
182
+ - Models already in favorites are not duplicated in recent
183
+ - Current model is marked with `✅`
184
+ - Default model from `OPENCODE_MODEL_PROVIDER` + `OPENCODE_MODEL_ID` is always included in favorites
186
185
 
187
- These favorites will appear in the `/model` command menu in Telegram.
188
-
189
- A free model (`opencode/big-pickle`) is configured as the default fallback — if you haven't set up any favorites yet, the bot will use it automatically.
186
+ To add a model to favorites, open OpenCode TUI (`opencode`), go to model selection, and press **Cmd+F/Ctrl+F** on the model.
190
187
 
191
188
  ## Security
192
189
 
@@ -239,9 +236,10 @@ npm run dev
239
236
  - Ensure `opencode serve` is running in your project directory
240
237
  - Check that `OPENCODE_API_URL` points to the correct address (default: `http://localhost:4096`)
241
238
 
242
- **No models in `/model` menu**
239
+ **No models in model picker**
243
240
 
244
241
  - Add models to your OpenCode favorites: open OpenCode TUI, go to model selection, press **Ctrl+F** on desired models
242
+ - Verify `OPENCODE_MODEL_PROVIDER` and `OPENCODE_MODEL_ID` point to an available model in your setup
245
243
 
246
244
  **Linux: permission denied errors**
247
245
 
@@ -9,8 +9,6 @@ const COMMAND_DEFINITIONS = [
9
9
  { command: "stop", descriptionKey: "cmd.description.stop" },
10
10
  { command: "sessions", descriptionKey: "cmd.description.sessions" },
11
11
  { command: "projects", descriptionKey: "cmd.description.projects" },
12
- { command: "model", descriptionKey: "cmd.description.model" },
13
- { command: "agent", descriptionKey: "cmd.description.agent" },
14
12
  { command: "rename", descriptionKey: "cmd.description.rename" },
15
13
  { command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
16
14
  { command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
@@ -3,7 +3,7 @@ import { getLocalizedBotCommands } from "./definitions.js";
3
3
  function formatHelpText() {
4
4
  const commands = getLocalizedBotCommands();
5
5
  const lines = commands.map((item) => `/${item.command} - ${item.description}`);
6
- return `📖 ${t("cmd.description.help")}\n\n${lines.join("\n")}`;
6
+ return `📖 ${t("cmd.description.help")}\n\n${lines.join("\n")}\n\n${t("help.keyboard_hint")}`;
7
7
  }
8
8
  export async function helpCommand(ctx) {
9
9
  await ctx.reply(formatHelpText());
@@ -6,45 +6,105 @@ import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
6
  import { summaryAggregator } from "../../summary/aggregator.js";
7
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
8
8
  import { keyboardManager } from "../../keyboard/manager.js";
9
- import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
9
+ import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
10
10
  import { logger } from "../../utils/logger.js";
11
11
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
12
12
  import { config } from "../../config.js";
13
13
  import { getDateLocale, t } from "../../i18n/index.js";
14
+ const SESSION_CALLBACK_PREFIX = "session:";
15
+ const SESSION_PAGE_CALLBACK_PREFIX = "session:page:";
16
+ const SESSION_FETCH_EXTRA_COUNT = 1;
17
+ function buildSessionPageCallback(page) {
18
+ return `${SESSION_PAGE_CALLBACK_PREFIX}${page}`;
19
+ }
20
+ function parseSessionPageCallback(data) {
21
+ if (!data.startsWith(SESSION_PAGE_CALLBACK_PREFIX)) {
22
+ return null;
23
+ }
24
+ const rawPage = data.slice(SESSION_PAGE_CALLBACK_PREFIX.length);
25
+ const page = Number(rawPage);
26
+ if (!Number.isInteger(page) || page < 0) {
27
+ return null;
28
+ }
29
+ return page;
30
+ }
31
+ function parseSessionIdCallback(data) {
32
+ if (!data.startsWith(SESSION_CALLBACK_PREFIX)) {
33
+ return null;
34
+ }
35
+ if (data.startsWith(SESSION_PAGE_CALLBACK_PREFIX)) {
36
+ return null;
37
+ }
38
+ const sessionId = data.slice(SESSION_CALLBACK_PREFIX.length);
39
+ return sessionId.length > 0 ? sessionId : null;
40
+ }
41
+ function formatSessionsSelectText(page) {
42
+ if (page === 0) {
43
+ return t("sessions.select");
44
+ }
45
+ return t("sessions.select_page", { page: page + 1 });
46
+ }
47
+ async function loadSessionPage(directory, page, pageSize) {
48
+ const startIndex = page * pageSize;
49
+ const endExclusive = startIndex + pageSize;
50
+ const { data: sessions, error } = await opencodeClient.session.list({
51
+ directory,
52
+ limit: endExclusive + SESSION_FETCH_EXTRA_COUNT,
53
+ });
54
+ if (error || !sessions) {
55
+ throw error || new Error("No data received from server");
56
+ }
57
+ const hasNext = sessions.length > endExclusive;
58
+ const pagedSessions = sessions.slice(startIndex, endExclusive);
59
+ logger.debug(`[Sessions] Loaded page=${page + 1}, startIndex=${startIndex}, endExclusive=${endExclusive}, pageSize=${pageSize}, items=${pagedSessions.length}, hasNext=${hasNext}`);
60
+ return {
61
+ sessions: pagedSessions,
62
+ hasNext,
63
+ page,
64
+ };
65
+ }
66
+ function buildSessionsKeyboard(pageData, pageSize) {
67
+ const keyboard = new InlineKeyboard();
68
+ const localeForDate = getDateLocale();
69
+ const pageStartIndex = pageData.page * pageSize;
70
+ pageData.sessions.forEach((session, index) => {
71
+ const date = new Date(session.time.created).toLocaleDateString(localeForDate);
72
+ const label = `${pageStartIndex + index + 1}. ${session.title} (${date})`;
73
+ keyboard.text(label, `${SESSION_CALLBACK_PREFIX}${session.id}`).row();
74
+ });
75
+ if (pageData.page > 0) {
76
+ keyboard.text(t("sessions.button.prev_page"), buildSessionPageCallback(pageData.page - 1));
77
+ }
78
+ if (pageData.hasNext) {
79
+ keyboard.text(t("sessions.button.next_page"), buildSessionPageCallback(pageData.page + 1));
80
+ }
81
+ if (pageData.page > 0 || pageData.hasNext) {
82
+ keyboard.row();
83
+ }
84
+ return keyboard;
85
+ }
14
86
  export async function sessionsCommand(ctx) {
15
87
  try {
16
- const maxSessions = config.bot.sessionsListLimit;
88
+ const pageSize = config.bot.sessionsListLimit;
17
89
  const currentProject = getCurrentProject();
18
90
  if (!currentProject) {
19
91
  await ctx.reply(t("sessions.project_not_selected"));
20
92
  return;
21
93
  }
22
94
  logger.debug(`[Sessions] Fetching sessions for directory: ${currentProject.worktree}`);
23
- const { data: sessions, error } = await opencodeClient.session.list({
24
- directory: currentProject.worktree,
25
- limit: maxSessions,
26
- });
27
- if (error || !sessions) {
28
- throw error || new Error("No data received from server");
29
- }
30
- logger.debug(`[Sessions] Found ${sessions.length} sessions`);
31
- sessions.forEach((session) => {
95
+ const firstPage = await loadSessionPage(currentProject.worktree, 0, pageSize);
96
+ logger.debug(`[Sessions] Found ${firstPage.sessions.length} sessions on page 1`);
97
+ firstPage.sessions.forEach((session) => {
32
98
  logger.debug(`[Sessions] Session: ${session.title} | ${session.directory}`);
33
99
  });
34
- if (sessions.length === 0) {
100
+ if (firstPage.sessions.length === 0) {
35
101
  await ctx.reply(t("sessions.empty"));
36
102
  return;
37
103
  }
38
- const keyboard = new InlineKeyboard();
39
- const localeForDate = getDateLocale();
40
- sessions.forEach((session, index) => {
41
- const date = new Date(session.time.created).toLocaleDateString(localeForDate);
42
- const label = `${index + 1}. ${session.title} (${date})`;
43
- keyboard.text(label, `session:${session.id}`).row();
44
- });
104
+ const keyboard = buildSessionsKeyboard(firstPage, pageSize);
45
105
  await replyWithInlineMenu(ctx, {
46
106
  menuKind: "session",
47
- text: t("sessions.select"),
107
+ text: formatSessionsSelectText(firstPage.page),
48
108
  keyboard,
49
109
  });
50
110
  }
@@ -55,10 +115,11 @@ export async function sessionsCommand(ctx) {
55
115
  }
56
116
  export async function handleSessionSelect(ctx) {
57
117
  const callbackQuery = ctx.callbackQuery;
58
- if (!callbackQuery?.data || !callbackQuery.data.startsWith("session:")) {
118
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
59
119
  return false;
60
120
  }
61
- const sessionId = callbackQuery.data.replace("session:", "");
121
+ const page = parseSessionPageCallback(callbackQuery.data);
122
+ const sessionId = parseSessionIdCallback(callbackQuery.data);
62
123
  const isActiveMenu = await ensureActiveInlineMenu(ctx, "session");
63
124
  if (!isActiveMenu) {
64
125
  return true;
@@ -71,6 +132,31 @@ export async function handleSessionSelect(ctx) {
71
132
  await ctx.reply(t("sessions.select_project_first"));
72
133
  return true;
73
134
  }
135
+ if (page !== null) {
136
+ try {
137
+ const pageSize = config.bot.sessionsListLimit;
138
+ const pageData = await loadSessionPage(currentProject.worktree, page, pageSize);
139
+ if (pageData.sessions.length === 0) {
140
+ await ctx.answerCallbackQuery({ text: t("sessions.page_empty_callback") });
141
+ return true;
142
+ }
143
+ const keyboard = buildSessionsKeyboard(pageData, pageSize);
144
+ appendInlineMenuCancelButton(keyboard, "session");
145
+ await ctx.editMessageText(formatSessionsSelectText(pageData.page), {
146
+ reply_markup: keyboard,
147
+ });
148
+ await ctx.answerCallbackQuery();
149
+ }
150
+ catch (error) {
151
+ logger.error("[Sessions] Error loading sessions page:", error);
152
+ await ctx.answerCallbackQuery({ text: t("sessions.page_load_error_callback") });
153
+ }
154
+ return true;
155
+ }
156
+ if (!sessionId) {
157
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
158
+ return true;
159
+ }
74
160
  const { data: session, error } = await opencodeClient.session.get({
75
161
  sessionID: sessionId,
76
162
  directory: currentProject.worktree,
@@ -8,6 +8,7 @@ import { formatModelForDisplay } from "../../model/types.js";
8
8
  import { processManager } from "../../process/manager.js";
9
9
  import { logger } from "../../utils/logger.js";
10
10
  import { t } from "../../i18n/index.js";
11
+ import { sendMessageWithMarkdownFallback } from "../utils/send-with-markdown-fallback.js";
11
12
  export async function statusCommand(ctx) {
12
13
  try {
13
14
  const { data, error } = await opencodeClient.global.health();
@@ -58,7 +59,17 @@ export async function statusCommand(ctx) {
58
59
  message += `\n${t("status.session_not_selected")}\n`;
59
60
  message += t("status.session_hint");
60
61
  }
61
- await ctx.reply(message, { parse_mode: "Markdown" });
62
+ if (ctx.chat) {
63
+ await sendMessageWithMarkdownFallback({
64
+ api: ctx.api,
65
+ chatId: ctx.chat.id,
66
+ text: message,
67
+ parseMode: "Markdown",
68
+ });
69
+ }
70
+ else {
71
+ await ctx.reply(message);
72
+ }
62
73
  }
63
74
  catch (error) {
64
75
  logger.error("[Bot] Error checking server status:", error);
@@ -1,5 +1,5 @@
1
1
  import { InlineKeyboard } from "grammy";
2
- import { selectModel, getFavoriteModels, fetchCurrentModel } from "../../model/manager.js";
2
+ import { selectModel, fetchCurrentModel, getModelSelectionLists } from "../../model/manager.js";
3
3
  import { formatModelForDisplay } from "../../model/types.js";
4
4
  import { formatVariantForButton } from "../../variant/manager.js";
5
5
  import { logger } from "../../utils/logger.js";
@@ -7,19 +7,18 @@ import { createMainKeyboard } from "../utils/keyboard.js";
7
7
  import { getStoredAgent } from "../../agent/manager.js";
8
8
  import { pinnedMessageManager } from "../../pinned/manager.js";
9
9
  import { keyboardManager } from "../../keyboard/manager.js";
10
- import { config } from "../../config.js";
11
10
  import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
12
11
  import { t } from "../../i18n/index.js";
13
- function isOnlyConfigDefaultModel(models) {
14
- if (models.length !== 1) {
15
- return false;
12
+ function buildModelSelectionMenuText(modelLists) {
13
+ const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
14
+ if (modelLists.favorites.length === 0) {
15
+ lines.push(t("model.menu.favorites_empty"));
16
16
  }
17
- const defaultProviderID = config.opencode.model.provider;
18
- const defaultModelID = config.opencode.model.modelId;
19
- if (!defaultProviderID || !defaultModelID) {
20
- return false;
17
+ lines.push(t("model.menu.recent_title"));
18
+ if (modelLists.recent.length === 0) {
19
+ lines.push(t("model.menu.recent_empty"));
21
20
  }
22
- return models[0].providerID === defaultProviderID && models[0].modelID === defaultModelID;
21
+ return lines.join("\n");
23
22
  }
24
23
  /**
25
24
  * Handle model selection callback
@@ -91,27 +90,30 @@ export async function handleModelSelect(ctx) {
91
90
  }
92
91
  }
93
92
  /**
94
- * Build inline keyboard with favorite models
93
+ * Build inline keyboard with favorite and recent models
95
94
  * @param currentModel Current model for highlighting
96
95
  * @returns InlineKeyboard with model selection buttons
97
96
  */
98
- export async function buildModelSelectionMenu(currentModel, favoriteModels) {
97
+ export async function buildModelSelectionMenu(currentModel, modelLists) {
99
98
  const keyboard = new InlineKeyboard();
100
- const favorites = favoriteModels ?? (await getFavoriteModels());
101
- if (favorites.length === 0) {
102
- logger.warn("[ModelHandler] No favorite models found");
99
+ const lists = modelLists ?? (await getModelSelectionLists());
100
+ const favorites = lists.favorites;
101
+ const recent = lists.recent;
102
+ if (favorites.length === 0 && recent.length === 0) {
103
+ logger.warn("[ModelHandler] No model choices found in favorites/recent");
103
104
  return keyboard;
104
105
  }
105
- // Add button for each favorite model
106
- favorites.forEach((model) => {
106
+ const addButton = (model, prefix) => {
107
107
  const isActive = currentModel &&
108
108
  model.providerID === currentModel.providerID &&
109
109
  model.modelID === currentModel.modelID;
110
110
  // Inline buttons use full model ID without truncation
111
- const label = `${model.providerID}/${model.modelID}`;
111
+ const label = `${prefix} ${model.providerID}/${model.modelID}`;
112
112
  const labelWithCheck = isActive ? `✅ ${label}` : label;
113
113
  keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
114
- });
114
+ };
115
+ favorites.forEach((model) => addButton(model, "⭐"));
116
+ recent.forEach((model) => addButton(model, "🕘"));
115
117
  return keyboard;
116
118
  }
117
119
  /**
@@ -121,22 +123,18 @@ export async function buildModelSelectionMenu(currentModel, favoriteModels) {
121
123
  export async function showModelSelectionMenu(ctx) {
122
124
  try {
123
125
  const currentModel = fetchCurrentModel();
124
- const favorites = await getFavoriteModels();
125
- const keyboard = await buildModelSelectionMenu(currentModel, favorites);
126
+ const modelLists = await getModelSelectionLists();
127
+ const keyboard = await buildModelSelectionMenu(currentModel, modelLists);
126
128
  if (keyboard.inline_keyboard.length === 0) {
127
129
  await ctx.reply(t("model.menu.empty"));
128
130
  return;
129
131
  }
130
- const displayName = formatModelForDisplay(currentModel.providerID, currentModel.modelID);
131
- const text = t("model.menu.current", { name: displayName });
132
+ const text = buildModelSelectionMenuText(modelLists);
132
133
  await replyWithInlineMenu(ctx, {
133
134
  menuKind: "model",
134
135
  text,
135
136
  keyboard,
136
137
  });
137
- if (isOnlyConfigDefaultModel(favorites)) {
138
- await ctx.reply(t("model.menu.favorites_hint"));
139
- }
140
138
  }
141
139
  catch (err) {
142
140
  logger.error("[ModelHandler] Error showing model menu:", err);
@@ -80,7 +80,10 @@ async function downloadTelegramFile(ctx, fileId) {
80
80
  logger.debug(`[Voice] Downloading file: ${file.file_path} (${file.file_size ?? "?"} bytes)`);
81
81
  const buffer = await downloadTelegramFileByUrl(fileUrl);
82
82
  // Extract filename from file_path (e.g., "voice/file_123.oga" -> "file_123.oga")
83
- const filename = file.file_path.split("/").pop() || "audio.ogg";
83
+ let filename = file.file_path.split("/").pop() || "audio.ogg";
84
+ if (filename.endsWith(".oga")) {
85
+ filename = filename.slice(0, -4) + ".ogg";
86
+ }
84
87
  logger.debug(`[Voice] Downloaded file: ${filename} (${buffer.length} bytes)`);
85
88
  return { buffer, filename };
86
89
  }
package/dist/bot/index.js CHANGED
@@ -19,8 +19,6 @@ import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
19
19
  import { stopCommand } from "./commands/stop.js";
20
20
  import { opencodeStartCommand } from "./commands/opencode-start.js";
21
21
  import { opencodeStopCommand } from "./commands/opencode-stop.js";
22
- import { handleAgentCommand } from "./commands/agent.js";
23
- import { handleModelCommand } from "./commands/model.js";
24
22
  import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./commands/rename.js";
25
23
  import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
26
24
  import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
@@ -53,6 +51,7 @@ let botInstance = null;
53
51
  let chatIdInstance = null;
54
52
  let commandsInitialized = false;
55
53
  const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
54
+ const SESSION_RETRY_PREFIX = "🔁";
56
55
  const __filename = fileURLToPath(import.meta.url);
57
56
  const __dirname = path.dirname(__filename);
58
57
  const TEMP_DIR = path.join(__dirname, "..", ".tmp");
@@ -323,6 +322,21 @@ async function ensureEventSubscription(directory) {
323
322
  logger.error("[Bot] Failed to send session.error message:", err);
324
323
  });
325
324
  });
325
+ summaryAggregator.setOnSessionRetry(async ({ sessionId, message }) => {
326
+ if (!botInstance || !chatIdInstance) {
327
+ return;
328
+ }
329
+ const currentSession = getCurrentSession();
330
+ if (!currentSession || currentSession.id !== sessionId) {
331
+ return;
332
+ }
333
+ const normalizedMessage = message.trim() || t("common.unknown_error");
334
+ const truncatedMessage = normalizedMessage.length > 3500
335
+ ? `${normalizedMessage.slice(0, 3497)}...`
336
+ : normalizedMessage;
337
+ const retryMessage = t("bot.session_retry", { message: truncatedMessage });
338
+ toolMessageBatcher.enqueueUniqueByPrefix(sessionId, retryMessage, SESSION_RETRY_PREFIX);
339
+ });
326
340
  summaryAggregator.setOnSessionDiff(async (_sessionId, diffs) => {
327
341
  if (!pinnedMessageManager.isInitialized()) {
328
342
  return;
@@ -440,8 +454,6 @@ export function createBot() {
440
454
  bot.command("projects", projectsCommand);
441
455
  bot.command("sessions", sessionsCommand);
442
456
  bot.command("new", newCommand);
443
- bot.command("agent", handleAgentCommand);
444
- bot.command("model", handleModelCommand);
445
457
  bot.command("stop", stopCommand);
446
458
  bot.command("rename", renameCommand);
447
459
  bot.on("message:text", unknownCommandMiddleware);
@@ -52,7 +52,7 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, optio
52
52
  if (!isTelegramMarkdownParseError(error)) {
53
53
  throw error;
54
54
  }
55
- logger.warn("[Bot] MarkdownV2 parse failed, retrying assistant message in raw mode", error);
55
+ logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
56
56
  await api.sendMessage(chatId, text, options);
57
57
  }
58
58
  }
package/dist/i18n/de.js CHANGED
@@ -4,8 +4,6 @@ export const de = {
4
4
  "cmd.description.stop": "Aktuelle Aktion stoppen",
5
5
  "cmd.description.sessions": "Sitzungen auflisten",
6
6
  "cmd.description.projects": "Projekte auflisten",
7
- "cmd.description.model": "Modell auswählen",
8
- "cmd.description.agent": "Modus auswählen",
9
7
  "cmd.description.opencode_start": "OpenCode-Server starten",
10
8
  "cmd.description.opencode_stop": "OpenCode-Server stoppen",
11
9
  "cmd.description.help": "Hilfe",
@@ -31,7 +29,8 @@ export const de = {
31
29
  "inline.cancelled_callback": "Abgebrochen",
32
30
  "common.unknown": "unbekannt",
33
31
  "common.unknown_error": "unbekannter Fehler",
34
- "start.welcome": "👋 Willkommen beim OpenCode Telegram Bot!\n\nNutze Befehle:\n/projects — Projekt auswählen\n/sessions — Sitzungsliste\n/new — neue Sitzung\n/agentModus wechseln\n/modelModell auswählen\n/status Status\n/help Hilfe",
32
+ "start.welcome": "👋 Willkommen beim OpenCode Telegram Bot!\n\nNutze Befehle:\n/projects — Projekt auswählen\n/sessions — Sitzungsliste\n/new — neue Sitzung\n/statusStatus\n/helpHilfe\n\nNutze die unteren Buttons, um Modus, Modell und Variante zu wählen.",
33
+ "help.keyboard_hint": "💡 Nutze die unteren Buttons für Modus, Modell, Variante und Kontextaktionen.",
35
34
  "help.text": "📖 **Hilfe**\n\n/status - Serverstatus prüfen\n/sessions - Sitzungsliste\n/new - Neue Sitzung erstellen\n/help - Hilfe",
36
35
  "bot.thinking": "💭 Denke...",
37
36
  "bot.project_not_selected": "🏗 Projekt ist nicht ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.",
@@ -42,6 +41,7 @@ export const de = {
42
41
  "bot.session_reset_project_mismatch": "⚠️ Die aktive Sitzung passt nicht zum ausgewählten Projekt und wurde daher zurückgesetzt. Nutze /sessions zur Auswahl oder /new, um eine neue Sitzung zu erstellen.",
43
42
  "bot.prompt_send_error": "Anfrage konnte nicht an OpenCode gesendet werden.",
44
43
  "bot.session_error": "🔴 OpenCode meldete einen Fehler: {message}",
44
+ "bot.session_retry": "🔁 {message}\n\nDer Provider liefert bei wiederholten Versuchen immer wieder denselben Fehler. Mit /stop abbrechen.",
45
45
  "bot.unknown_command": "⚠️ Unbekannter Befehl: {command}. Nutze /help, um verfügbare Befehle zu sehen.",
46
46
  "bot.photo_downloading": "⏳ Lade Foto herunter...",
47
47
  "bot.photo_too_large": "⚠️ Foto ist zu groß (max. {maxSizeMb}MB)",
@@ -76,8 +76,13 @@ export const de = {
76
76
  "sessions.project_not_selected": "🏗 Projekt ist nicht ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.",
77
77
  "sessions.empty": "📭 Keine Sitzungen gefunden.\n\nErstelle eine neue Sitzung mit /new.",
78
78
  "sessions.select": "Sitzung auswählen:",
79
+ "sessions.select_page": "Sitzung auswählen (Seite {page}):",
79
80
  "sessions.fetch_error": "🔴 OpenCode-Server ist nicht verfügbar oder beim Laden der Sitzungen ist ein Fehler aufgetreten.",
80
81
  "sessions.select_project_first": "🔴 Projekt ist nicht ausgewählt. Nutze /projects.",
82
+ "sessions.page_empty_callback": "Auf dieser Seite gibt es keine Sitzungen",
83
+ "sessions.page_load_error_callback": "Diese Seite kann nicht geladen werden. Bitte versuche es erneut.",
84
+ "sessions.button.prev_page": "⬅️ Zurück",
85
+ "sessions.button.next_page": "Weiter ➡️",
81
86
  "sessions.loading_context": "⏳ Lade Kontext und letzte Nachrichten...",
82
87
  "sessions.selected": "✅ Sitzung ausgewählt: {title}",
83
88
  "sessions.select_error": "🔴 Sitzung konnte nicht ausgewählt werden.",
@@ -119,8 +124,13 @@ export const de = {
119
124
  "model.changed_message": "✅ Modell geändert zu: {name}",
120
125
  "model.change_error_callback": "Modell konnte nicht geändert werden",
121
126
  "model.menu.empty": "⚠️ Keine verfügbaren Modelle",
127
+ "model.menu.select": "Modell auswählen:",
122
128
  "model.menu.current": "Aktuelles Modell: {name}\n\nModell auswählen:",
123
- "model.menu.favorites_hint": "ℹ️ Die Modellliste wird aus Favoriten in OpenCode CLI erstellt.",
129
+ "model.menu.favorites_title": " Favoriten (Füge Modelle in OpenCode CLI zu den Favoriten hinzu)",
130
+ "model.menu.favorites_empty": "— Leer.",
131
+ "model.menu.recent_title": "🕘 Zuletzt verwendet",
132
+ "model.menu.recent_empty": "— Leer.",
133
+ "model.menu.favorites_hint": "ℹ️ Füge Modelle in OpenCode CLI zu den Favoriten hinzu, damit sie oben angezeigt werden.",
124
134
  "model.menu.error": "🔴 Modellliste konnte nicht geladen werden",
125
135
  "variant.model_not_selected_callback": "Fehler: Modell ist nicht ausgewählt",
126
136
  "variant.changed_callback": "Variante geändert: {name}",
package/dist/i18n/en.js CHANGED
@@ -4,8 +4,6 @@ export const en = {
4
4
  "cmd.description.stop": "Stop current action",
5
5
  "cmd.description.sessions": "List sessions",
6
6
  "cmd.description.projects": "List projects",
7
- "cmd.description.model": "Select model",
8
- "cmd.description.agent": "Select mode",
9
7
  "cmd.description.opencode_start": "Start OpenCode server",
10
8
  "cmd.description.opencode_stop": "Stop OpenCode server",
11
9
  "cmd.description.help": "Help",
@@ -31,7 +29,8 @@ export const en = {
31
29
  "inline.cancelled_callback": "Cancelled",
32
30
  "common.unknown": "unknown",
33
31
  "common.unknown_error": "unknown error",
34
- "start.welcome": "👋 Welcome to OpenCode Telegram Bot!\n\nUse commands:\n/projects — select project\n/sessions — session list\n/new — new session\n/agentswitch mode\n/modelselect model\n/status status\n/help help",
32
+ "start.welcome": "👋 Welcome to OpenCode Telegram Bot!\n\nUse commands:\n/projects — select project\n/sessions — session list\n/new — new session\n/statusstatus\n/helphelp\n\nUse the bottom buttons to select agent mode, model, and variant.",
33
+ "help.keyboard_hint": "💡 Use the bottom keyboard buttons for agent mode, model, variant, and context actions.",
35
34
  "help.text": "📖 **Help**\n\n/status - Check server status\n/sessions - Session list\n/new - Create new session\n/help - Help",
36
35
  "bot.thinking": "💭 Thinking...",
37
36
  "bot.project_not_selected": "🏗 Project is not selected.\n\nFirst select a project with /projects.",
@@ -42,6 +41,7 @@ export const en = {
42
41
  "bot.session_reset_project_mismatch": "⚠️ Active session does not match the selected project, so it was reset. Use /sessions to pick one or /new to create a new session.",
43
42
  "bot.prompt_send_error": "Failed to send request to OpenCode.",
44
43
  "bot.session_error": "🔴 OpenCode returned an error: {message}",
44
+ "bot.session_retry": "🔁 {message}\n\nProvider keeps returning the same error on repeated retries. Use /stop to abort.",
45
45
  "bot.unknown_command": "⚠️ Unknown command: {command}. Use /help to see available commands.",
46
46
  "bot.photo_downloading": "⏳ Downloading photo...",
47
47
  "bot.photo_too_large": "⚠️ Photo is too large (max {maxSizeMb}MB)",
@@ -76,8 +76,13 @@ export const en = {
76
76
  "sessions.project_not_selected": "🏗 Project is not selected.\n\nFirst select a project with /projects.",
77
77
  "sessions.empty": "📭 No sessions found.\n\nCreate a new session with /new.",
78
78
  "sessions.select": "Select a session:",
79
+ "sessions.select_page": "Select a session (page {page}):",
79
80
  "sessions.fetch_error": "🔴 OpenCode Server is unavailable or an error occurred while loading sessions.",
80
81
  "sessions.select_project_first": "🔴 Project is not selected. Use /projects.",
82
+ "sessions.page_empty_callback": "No sessions on this page",
83
+ "sessions.page_load_error_callback": "Cannot load this page. Please try again.",
84
+ "sessions.button.prev_page": "⬅️ Prev",
85
+ "sessions.button.next_page": "Next ➡️",
81
86
  "sessions.loading_context": "⏳ Loading context and latest messages...",
82
87
  "sessions.selected": "✅ Session selected: {title}",
83
88
  "sessions.select_error": "🔴 Failed to select session.",
@@ -119,8 +124,13 @@ export const en = {
119
124
  "model.changed_message": "✅ Model changed to: {name}",
120
125
  "model.change_error_callback": "Failed to change model",
121
126
  "model.menu.empty": "⚠️ No available models",
127
+ "model.menu.select": "Select model:",
122
128
  "model.menu.current": "Current model: {name}\n\nSelect model:",
123
- "model.menu.favorites_hint": "ℹ️ The model list is built from favorites in OpenCode CLI.",
129
+ "model.menu.favorites_title": " Favorites (Add models to favorites in OpenCode CLI)",
130
+ "model.menu.favorites_empty": "— Empty.",
131
+ "model.menu.recent_title": "🕘 Recent",
132
+ "model.menu.recent_empty": "— Empty.",
133
+ "model.menu.favorites_hint": "ℹ️ Add models to favorites in OpenCode CLI to keep them at the top.",
124
134
  "model.menu.error": "🔴 Failed to get models list",
125
135
  "variant.model_not_selected_callback": "Error: model is not selected",
126
136
  "variant.changed_callback": "Variant changed: {name}",
package/dist/i18n/es.js CHANGED
@@ -4,8 +4,6 @@ export const es = {
4
4
  "cmd.description.stop": "Detener la acción actual",
5
5
  "cmd.description.sessions": "Listar sesiones",
6
6
  "cmd.description.projects": "Listar proyectos",
7
- "cmd.description.model": "Seleccionar modelo",
8
- "cmd.description.agent": "Seleccionar modo",
9
7
  "cmd.description.opencode_start": "Iniciar servidor OpenCode",
10
8
  "cmd.description.opencode_stop": "Detener servidor OpenCode",
11
9
  "cmd.description.help": "Ayuda",
@@ -31,7 +29,8 @@ export const es = {
31
29
  "inline.cancelled_callback": "Cancelado",
32
30
  "common.unknown": "desconocido",
33
31
  "common.unknown_error": "error desconocido",
34
- "start.welcome": "👋 ¡Bienvenido a OpenCode Telegram Bot!\n\nUsa los comandos:\n/projects — seleccionar proyecto\n/sessions — lista de sesiones\n/new — sesión nueva\n/agentcambiar modo\n/modelseleccionar modelo\n/status estado\n/help ayuda",
32
+ "start.welcome": "👋 ¡Bienvenido a OpenCode Telegram Bot!\n\nUsa los comandos:\n/projects — seleccionar proyecto\n/sessions — lista de sesiones\n/new — sesión nueva\n/statusestado\n/helpayuda\n\nUsa los botones inferiores para elegir modo, modelo y variante.",
33
+ "help.keyboard_hint": "💡 Usa los botones inferiores para modo del agente, modelo, variante y acciones de contexto.",
35
34
  "help.text": "📖 **Ayuda**\n\n/status - Ver estado del servidor\n/sessions - Lista de sesiones\n/new - Crear una sesión nueva\n/help - Ayuda",
36
35
  "bot.thinking": "💭 Pensando...",
37
36
  "bot.project_not_selected": "🏗 No hay un proyecto seleccionado.\n\nPrimero selecciona un proyecto con /projects.",
@@ -42,6 +41,7 @@ export const es = {
42
41
  "bot.session_reset_project_mismatch": "⚠️ La sesión activa no coincide con el proyecto seleccionado, así que se reinició. Usa /sessions para elegir una o /new para crear una nueva.",
43
42
  "bot.prompt_send_error": "No se pudo enviar la solicitud a OpenCode.",
44
43
  "bot.session_error": "🔴 OpenCode devolvió un error: {message}",
44
+ "bot.session_retry": "🔁 {message}\n\nEl proveedor devuelve el mismo error en intentos repetidos. Usa /stop para detenerlo.",
45
45
  "bot.unknown_command": "⚠️ Comando desconocido: {command}. Usa /help para ver los comandos disponibles.",
46
46
  "bot.photo_downloading": "⏳ Descargando foto...",
47
47
  "bot.photo_too_large": "⚠️ La foto es demasiado grande (max {maxSizeMb}MB)",
@@ -76,8 +76,13 @@ export const es = {
76
76
  "sessions.project_not_selected": "🏗 No hay un proyecto seleccionado.\n\nPrimero selecciona un proyecto con /projects.",
77
77
  "sessions.empty": "📭 No se encontraron sesiones.\n\nCrea una sesión nueva con /new.",
78
78
  "sessions.select": "Selecciona una sesión:",
79
+ "sessions.select_page": "Selecciona una sesión (página {page}):",
79
80
  "sessions.fetch_error": "🔴 OpenCode Server no está disponible u ocurrió un error al cargar las sesiones.",
80
81
  "sessions.select_project_first": "🔴 No hay un proyecto seleccionado. Usa /projects.",
82
+ "sessions.page_empty_callback": "No hay sesiones en esta página",
83
+ "sessions.page_load_error_callback": "No se puede cargar esta página. Inténtalo de nuevo.",
84
+ "sessions.button.prev_page": "⬅️ Anterior",
85
+ "sessions.button.next_page": "Siguiente ➡️",
81
86
  "sessions.loading_context": "⏳ Cargando contexto y los últimos mensajes...",
82
87
  "sessions.selected": "✅ Sesión seleccionada: {title}",
83
88
  "sessions.select_error": "🔴 No se pudo seleccionar la sesión.",
@@ -119,8 +124,13 @@ export const es = {
119
124
  "model.changed_message": "✅ Modelo cambiado a: {name}",
120
125
  "model.change_error_callback": "No se pudo cambiar el modelo",
121
126
  "model.menu.empty": "⚠️ No hay modelos disponibles",
127
+ "model.menu.select": "Selecciona el modelo:",
122
128
  "model.menu.current": "Modelo actual: {name}\n\nSelecciona el modelo:",
123
- "model.menu.favorites_hint": "ℹ️ La lista de modelos se genera a partir de favoritos en OpenCode CLI.",
129
+ "model.menu.favorites_title": " Favoritos (Agrega modelos a favoritos en OpenCode CLI)",
130
+ "model.menu.favorites_empty": "— Vacío.",
131
+ "model.menu.recent_title": "🕘 Recientes",
132
+ "model.menu.recent_empty": "— Vacío.",
133
+ "model.menu.favorites_hint": "ℹ️ Agrega modelos a favoritos en OpenCode CLI para mantenerlos arriba de la lista.",
124
134
  "model.menu.error": "🔴 No se pudo obtener la lista de modelos",
125
135
  "variant.model_not_selected_callback": "Error: no hay un modelo seleccionado",
126
136
  "variant.changed_callback": "Variante cambiada: {name}",
package/dist/i18n/ru.js CHANGED
@@ -4,8 +4,6 @@ export const ru = {
4
4
  "cmd.description.stop": "Прервать текущее действие",
5
5
  "cmd.description.sessions": "Список сессий",
6
6
  "cmd.description.projects": "Список проектов",
7
- "cmd.description.model": "Выбрать модель",
8
- "cmd.description.agent": "Выбрать режим работы",
9
7
  "cmd.description.opencode_start": "Запустить OpenCode сервер",
10
8
  "cmd.description.opencode_stop": "Остановить OpenCode сервер",
11
9
  "cmd.description.help": "Справка",
@@ -31,7 +29,8 @@ export const ru = {
31
29
  "inline.cancelled_callback": "Отменено",
32
30
  "common.unknown": "неизвестна",
33
31
  "common.unknown_error": "неизвестная ошибка",
34
- "start.welcome": "👋 Добро пожаловать в OpenCode Telegram Bot!\n\nИспользуйте команды:\n/projects — выбрать проект\n/sessions — список сессий\n/new — новая сессия\n/agentсменить режим\n/modelвыбрать модель\n/status статус\n/help справка",
32
+ "start.welcome": "👋 Добро пожаловать в OpenCode Telegram Bot!\n\nИспользуйте команды:\n/projects — выбрать проект\n/sessions — список сессий\n/new — новая сессия\n/statusстатус\n/helpсправка\n\nРежим, модель и вариант выбираются кнопками внизу.",
33
+ "help.keyboard_hint": "💡 Режим, модель, вариант и действия с контекстом доступны через нижние кнопки клавиатуры.",
35
34
  "help.text": "📖 **Справка**\n\n/status - Проверить статус сервера\n/sessions - Список сессий\n/new - Создать новую сессию\n/help - Справка",
36
35
  "bot.thinking": "💭 Думаю...",
37
36
  "bot.project_not_selected": "🏗 Проект не выбран.\n\nСначала выберите проект командой /projects.",
@@ -42,6 +41,7 @@ export const ru = {
42
41
  "bot.session_reset_project_mismatch": "⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.",
43
42
  "bot.prompt_send_error": "Не удалось отправить запрос в OpenCode.",
44
43
  "bot.session_error": "🔴 OpenCode вернул ошибку: {message}",
44
+ "bot.session_retry": "🔁 {message}\n\nПровайдер возвращает одну и ту же ошибку при повторных запросах. Используйте /stop для остановки.",
45
45
  "bot.unknown_command": "⚠️ Неизвестная команда: {command}. Используйте /help для списка команд.",
46
46
  "bot.photo_downloading": "⏳ Скачиваю фото...",
47
47
  "bot.photo_too_large": "⚠️ Фото слишком большое (макс. {maxSizeMb}МБ)",
@@ -76,8 +76,13 @@ export const ru = {
76
76
  "sessions.project_not_selected": "🏗 Проект не выбран.\n\nСначала выберите проект командой /projects.",
77
77
  "sessions.empty": "📭 Сессий нет.\n\nСоздайте новую сессию командой /new.",
78
78
  "sessions.select": "Выберите сессию:",
79
+ "sessions.select_page": "Выберите сессию (страница {page}):",
79
80
  "sessions.fetch_error": "🔴 OpenCode Server недоступен или произошла ошибка при получении списка сессий.",
80
81
  "sessions.select_project_first": "🔴 Проект не выбран. Используйте /projects.",
82
+ "sessions.page_empty_callback": "На этой странице нет сессий",
83
+ "sessions.page_load_error_callback": "Не удалось загрузить эту страницу. Пожалуйста, попробуйте снова.",
84
+ "sessions.button.prev_page": "⬅️ Назад",
85
+ "sessions.button.next_page": "Вперёд ➡️",
81
86
  "sessions.loading_context": "⏳ Загружаю контекст и последние сообщения...",
82
87
  "sessions.selected": "✅ Сессия выбрана: {title}",
83
88
  "sessions.select_error": "🔴 Ошибка при выборе сессии.",
@@ -119,16 +124,21 @@ export const ru = {
119
124
  "model.changed_message": "✅ Модель изменена на: {name}",
120
125
  "model.change_error_callback": "Ошибка при смене модели",
121
126
  "model.menu.empty": "⚠️ Нет доступных моделей",
127
+ "model.menu.select": "Выберите модель:",
122
128
  "model.menu.current": "Текущая модель: {name}\n\nВыберите модель:",
123
- "model.menu.favorites_hint": "ℹ️ Список моделей формируется из favorites в OpenCode CLI.",
129
+ "model.menu.favorites_title": " Избранное (Добавляйте модели в избранное через OpenCode CLI)",
130
+ "model.menu.favorites_empty": "— Список пуст.",
131
+ "model.menu.recent_title": "🕘 Недавние",
132
+ "model.menu.recent_empty": "— Список пуст.",
133
+ "model.menu.favorites_hint": "ℹ️ Добавляйте модели в избранное через OpenCode CLI, чтобы они были вверху списка.",
124
134
  "model.menu.error": "🔴 Не удалось получить список моделей",
125
135
  "variant.model_not_selected_callback": "Ошибка: модель не выбрана",
126
- "variant.changed_callback": "Variant изменен: {name}",
127
- "variant.changed_message": "✅ Variant изменен на: {name}",
128
- "variant.change_error_callback": "Ошибка при смене variant",
136
+ "variant.changed_callback": "Вариант изменен: {name}",
137
+ "variant.changed_message": "✅ Вариант изменен на: {name}",
138
+ "variant.change_error_callback": "Ошибка при смене варианта",
129
139
  "variant.select_model_first": "⚠️ Сначала выберите модель",
130
140
  "variant.menu.empty": "⚠️ Нет доступных вариантов",
131
- "variant.menu.current": "Текущий variant: {name}\n\nВыберите variant:",
141
+ "variant.menu.current": "Текущий вариант: {name}\n\nВыберите вариант:",
132
142
  "variant.menu.error": "🔴 Не удалось получить список вариантов",
133
143
  "context.button.confirm": "✅ Да, сжать контекст",
134
144
  "context.no_active_session": "⚠️ Нет активной сессии. Создайте сессию командой /new",
package/dist/i18n/zh.js CHANGED
@@ -4,8 +4,6 @@ export const zh = {
4
4
  "cmd.description.stop": "停止当前操作",
5
5
  "cmd.description.sessions": "列出会话",
6
6
  "cmd.description.projects": "列出项目",
7
- "cmd.description.model": "选择模型",
8
- "cmd.description.agent": "选择模式",
9
7
  "cmd.description.opencode_start": "启动 OpenCode 服务器",
10
8
  "cmd.description.opencode_stop": "停止 OpenCode 服务器",
11
9
  "cmd.description.help": "帮助",
@@ -31,7 +29,8 @@ export const zh = {
31
29
  "inline.cancelled_callback": "已取消",
32
30
  "common.unknown": "未知",
33
31
  "common.unknown_error": "未知错误",
34
- "start.welcome": "👋 欢迎使用 OpenCode Telegram Bot!\n\n可用命令:\n/projects — 选择项目\n/sessions — 会话列表\n/new — 新建会话\n/agent — 切换模式\n/model — 选择模型\n/status — 状态\n/help — 帮助",
32
+ "start.welcome": "👋 欢迎使用 OpenCode Telegram Bot!\n\n可用命令:\n/projects — 选择项目\n/sessions — 会话列表\n/new — 新建会话\n/status — 状态\n/help — 帮助\n\n请使用底部按钮选择模式、模型和变体。",
33
+ "help.keyboard_hint": "💡 代理模式、模型、变体和上下文操作请使用底部键盘按钮。",
35
34
  "help.text": "📖 **帮助**\n\n/status - 查看服务器状态\n/sessions - 会话列表\n/new - 创建新会话\n/help - 帮助",
36
35
  "bot.thinking": "💭 思考中...",
37
36
  "bot.project_not_selected": "🏗 未选择项目。\n\n请先使用 /projects 选择一个项目。",
@@ -42,6 +41,7 @@ export const zh = {
42
41
  "bot.session_reset_project_mismatch": "⚠️ 活动会话与所选项目不匹配,因此已重置。使用 /sessions 选择一个会话,或 /new 创建新会话。",
43
42
  "bot.prompt_send_error": "向 OpenCode 发送请求失败。",
44
43
  "bot.session_error": "🔴 OpenCode 返回错误:{message}",
44
+ "bot.session_retry": "🔁 {message}\n\n提供方在重复重试时持续返回同一错误。使用 /stop 可停止。",
45
45
  "bot.unknown_command": "⚠️ 未知命令:{command}。使用 /help 查看可用命令。",
46
46
  "bot.photo_downloading": "⏳ 正在下载照片...",
47
47
  "bot.photo_too_large": "⚠️ 照片过大(最大 {maxSizeMb}MB)",
@@ -76,8 +76,13 @@ export const zh = {
76
76
  "sessions.project_not_selected": "🏗 未选择项目。\n\n请先使用 /projects 选择一个项目。",
77
77
  "sessions.empty": "📭 未找到会话。\n\n使用 /new 创建新会话。",
78
78
  "sessions.select": "请选择一个会话:",
79
+ "sessions.select_page": "请选择一个会话(第 {page} 页):",
79
80
  "sessions.fetch_error": "🔴 OpenCode 服务器不可用,或加载会话时发生错误。",
80
81
  "sessions.select_project_first": "🔴 未选择项目。使用 /projects。",
82
+ "sessions.page_empty_callback": "这一页没有会话",
83
+ "sessions.page_load_error_callback": "无法加载此页面。请重试。",
84
+ "sessions.button.prev_page": "⬅️ 上一页",
85
+ "sessions.button.next_page": "下一页 ➡️",
81
86
  "sessions.loading_context": "⏳ 正在加载上下文和最新消息...",
82
87
  "sessions.selected": "✅ 已选择会话:{title}",
83
88
  "sessions.select_error": "🔴 选择会话失败。",
@@ -119,8 +124,13 @@ export const zh = {
119
124
  "model.changed_message": "✅ 模型已切换为:{name}",
120
125
  "model.change_error_callback": "切换模型失败",
121
126
  "model.menu.empty": "⚠️ 没有可用模型",
127
+ "model.menu.select": "请选择模型:",
122
128
  "model.menu.current": "当前模型:{name}\n\n请选择模型:",
123
- "model.menu.favorites_hint": "ℹ️ 模型列表来自 OpenCode CLI 的收藏(favorites)。",
129
+ "model.menu.favorites_title": " 收藏(可在 OpenCode CLI 中将模型加入收藏)",
130
+ "model.menu.favorites_empty": "— 列表为空。",
131
+ "model.menu.recent_title": "🕘 最近使用",
132
+ "model.menu.recent_empty": "— 列表为空。",
133
+ "model.menu.favorites_hint": "ℹ️ 可在 OpenCode CLI 中将模型加入收藏,使其显示在列表顶部。",
124
134
  "model.menu.error": "🔴 获取模型列表失败",
125
135
  "variant.model_not_selected_callback": "错误:未选择模型",
126
136
  "variant.changed_callback": "变体已更改:{name}",
@@ -34,6 +34,20 @@ function normalizeFavoriteModels(state) {
34
34
  modelID: model.modelID,
35
35
  }));
36
36
  }
37
+ function normalizeRecentModels(state) {
38
+ if (!Array.isArray(state.recent)) {
39
+ return [];
40
+ }
41
+ return state.recent
42
+ .filter((model) => typeof model?.providerID === "string" &&
43
+ model.providerID.length > 0 &&
44
+ typeof model.modelID === "string" &&
45
+ model.modelID.length > 0)
46
+ .map((model) => ({
47
+ providerID: model.providerID,
48
+ modelID: model.modelID,
49
+ }));
50
+ }
37
51
  function getOpenCodeModelStatePath() {
38
52
  const xdgStateHome = process.env.XDG_STATE_HOME;
39
53
  if (xdgStateHome && xdgStateHome.trim().length > 0) {
@@ -43,38 +57,54 @@ function getOpenCodeModelStatePath() {
43
57
  return path.join(homeDir, ".local", "state", "opencode", "model.json");
44
58
  }
45
59
  /**
46
- * Get list of favorite models from OpenCode local state file
47
- * Falls back to env default model if file is unavailable or empty
60
+ * Get favorite and recent models from OpenCode local state file.
61
+ * Config model is always treated as favorite.
48
62
  */
49
- export async function getFavoriteModels() {
63
+ export async function getModelSelectionLists() {
50
64
  const envDefaultModel = getEnvDefaultModel();
51
65
  try {
52
66
  const fs = await import("fs/promises");
53
67
  const stateFilePath = getOpenCodeModelStatePath();
54
68
  const content = await fs.readFile(stateFilePath, "utf-8");
55
69
  const state = JSON.parse(content);
56
- const favorites = normalizeFavoriteModels(state);
70
+ const rawFavorites = normalizeFavoriteModels(state);
71
+ const favorites = envDefaultModel
72
+ ? dedupeModels([...rawFavorites, envDefaultModel])
73
+ : rawFavorites;
74
+ if (rawFavorites.length === 0 && envDefaultModel) {
75
+ logger.info(`[ModelManager] No favorites in ${stateFilePath}, using config model as favorite`);
76
+ }
57
77
  if (favorites.length === 0) {
58
- if (envDefaultModel) {
59
- logger.info(`[ModelManager] No favorites in ${stateFilePath}, using env default model`);
60
- return [envDefaultModel];
61
- }
62
78
  logger.warn(`[ModelManager] No favorites in ${stateFilePath}`);
63
- return [];
64
79
  }
65
- const merged = envDefaultModel ? dedupeModels([...favorites, envDefaultModel]) : favorites;
66
- logger.debug(`[ModelManager] Loaded ${merged.length} favorite models from ${stateFilePath}`);
67
- return merged;
80
+ const favoriteKeys = new Set(favorites.map((model) => `${model.providerID}/${model.modelID}`));
81
+ const recent = dedupeModels(normalizeRecentModels(state)).filter((model) => !favoriteKeys.has(`${model.providerID}/${model.modelID}`));
82
+ logger.debug(`[ModelManager] Loaded model selection lists from ${stateFilePath}: favorites=${favorites.length}, recent=${recent.length}`);
83
+ return { favorites, recent };
68
84
  }
69
85
  catch (err) {
70
86
  if (envDefaultModel) {
71
- logger.warn("[ModelManager] Failed to load OpenCode favorites, using env default model:", err);
72
- return [envDefaultModel];
87
+ logger.warn("[ModelManager] Failed to load OpenCode model state, using config model as favorite:", err);
88
+ return {
89
+ favorites: [envDefaultModel],
90
+ recent: [],
91
+ };
73
92
  }
74
- logger.error("[ModelManager] Failed to load OpenCode favorites:", err);
75
- return [];
93
+ logger.error("[ModelManager] Failed to load OpenCode model state:", err);
94
+ return {
95
+ favorites: [],
96
+ recent: [],
97
+ };
76
98
  }
77
99
  }
100
+ /**
101
+ * Get list of favorite models from OpenCode local state file
102
+ * Falls back to env default model if file is unavailable or empty
103
+ */
104
+ export async function getFavoriteModels() {
105
+ const { favorites } = await getModelSelectionLists();
106
+ return favorites;
107
+ }
78
108
  /**
79
109
  * Get current model from settings or fallback to config
80
110
  * @returns Current model info
@@ -40,6 +40,7 @@ class SummaryAggregator {
40
40
  onTokensCallback = null;
41
41
  onSessionCompactedCallback = null;
42
42
  onSessionErrorCallback = null;
43
+ onSessionRetryCallback = null;
43
44
  onPermissionCallback = null;
44
45
  onSessionDiffCallback = null;
45
46
  onFileChangeCallback = null;
@@ -81,6 +82,9 @@ class SummaryAggregator {
81
82
  setOnSessionError(callback) {
82
83
  this.onSessionErrorCallback = callback;
83
84
  }
85
+ setOnSessionRetry(callback) {
86
+ this.onSessionRetryCallback = callback;
87
+ }
84
88
  setOnPermission(callback) {
85
89
  this.onPermissionCallback = callback;
86
90
  }
@@ -434,10 +438,24 @@ class SummaryAggregator {
434
438
  return hash.toString(36);
435
439
  }
436
440
  handleSessionStatus(event) {
437
- const { sessionID } = event.properties;
441
+ const { sessionID, status } = event.properties;
438
442
  if (sessionID !== this.currentSessionId) {
439
443
  return;
440
444
  }
445
+ if (status?.type !== "retry" || !this.onSessionRetryCallback) {
446
+ return;
447
+ }
448
+ const callback = this.onSessionRetryCallback;
449
+ const message = status.message?.trim() || "Unknown retry error";
450
+ logger.warn(`[Aggregator] Session retry: session=${sessionID}, attempt=${status.attempt ?? "n/a"}, message=${message}`);
451
+ setImmediate(() => {
452
+ callback({
453
+ sessionId: sessionID,
454
+ attempt: status.attempt,
455
+ message,
456
+ next: status.next,
457
+ });
458
+ });
441
459
  }
442
460
  handleSessionIdle(event) {
443
461
  const { sessionID } = event.properties;
@@ -44,21 +44,10 @@ export class ToolMessageBatcher {
44
44
  return this.intervalSeconds;
45
45
  }
46
46
  enqueue(sessionId, message) {
47
- const normalizedMessage = message.trim();
48
- if (!sessionId || normalizedMessage.length === 0) {
49
- return;
50
- }
51
- if (this.intervalSeconds === 0) {
52
- const expectedGeneration = this.generation;
53
- logger.debug(`[ToolBatcher] Sending immediate text message: session=${sessionId}`);
54
- void this.enqueueTask(sessionId, () => this.sendTextSafe(sessionId, normalizedMessage, "immediate", expectedGeneration));
55
- return;
56
- }
57
- const queue = this.queues.get(sessionId) ?? [];
58
- queue.push({ kind: "text", text: normalizedMessage });
59
- this.queues.set(sessionId, queue);
60
- logger.debug(`[ToolBatcher] Queued text message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
61
- this.ensureTimer(sessionId);
47
+ this.enqueueTextInternal(sessionId, message);
48
+ }
49
+ enqueueUniqueByPrefix(sessionId, message, prefix) {
50
+ this.enqueueTextInternal(sessionId, message, prefix);
62
51
  }
63
52
  enqueueFile(sessionId, fileData) {
64
53
  if (!sessionId) {
@@ -142,6 +131,34 @@ export class ToolMessageBatcher {
142
131
  this.sessionTasks.set(sessionId, nextTask);
143
132
  return nextTask;
144
133
  }
134
+ enqueueTextInternal(sessionId, message, uniquePrefix) {
135
+ const normalizedMessage = message.trim();
136
+ if (!sessionId || normalizedMessage.length === 0) {
137
+ return;
138
+ }
139
+ if (this.intervalSeconds === 0) {
140
+ const expectedGeneration = this.generation;
141
+ logger.debug(`[ToolBatcher] Sending immediate text message: session=${sessionId}`);
142
+ void this.enqueueTask(sessionId, () => this.sendTextSafe(sessionId, normalizedMessage, "immediate", expectedGeneration));
143
+ return;
144
+ }
145
+ const normalizedPrefix = uniquePrefix?.trim();
146
+ const queue = this.queues.get(sessionId) ?? [];
147
+ if (normalizedPrefix) {
148
+ const existingUniqueMessage = queue.find((item) => item.kind === "text" && item.text.startsWith(normalizedPrefix));
149
+ if (existingUniqueMessage) {
150
+ existingUniqueMessage.text = normalizedMessage;
151
+ this.queues.set(sessionId, queue);
152
+ logger.debug(`[ToolBatcher] Updated queued unique text message: session=${sessionId}, prefix=${normalizedPrefix}, interval=${this.intervalSeconds}s`);
153
+ this.ensureTimer(sessionId);
154
+ return;
155
+ }
156
+ }
157
+ queue.push({ kind: "text", text: normalizedMessage });
158
+ this.queues.set(sessionId, queue);
159
+ logger.debug(`[ToolBatcher] Queued text message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
160
+ this.ensureTimer(sessionId);
161
+ }
145
162
  async flushSessionInternal(sessionId, reason) {
146
163
  const expectedGeneration = this.generation;
147
164
  this.clearTimer(sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,17 +0,0 @@
1
- import { showAgentSelectionMenu } from "../handlers/agent.js";
2
- import { logger } from "../../utils/logger.js";
3
- import { t } from "../../i18n/index.js";
4
- /**
5
- * Handler for /agent command
6
- * Shows inline menu to select agent mode
7
- */
8
- export async function handleAgentCommand(ctx) {
9
- logger.debug("[AgentCommand] /agent command received");
10
- try {
11
- await showAgentSelectionMenu(ctx);
12
- }
13
- catch (err) {
14
- logger.error("[AgentCommand] Error showing agent menu:", err);
15
- await ctx.reply(t("error.load_agents"));
16
- }
17
- }
@@ -1,17 +0,0 @@
1
- import { showModelSelectionMenu } from "../handlers/model.js";
2
- import { logger } from "../../utils/logger.js";
3
- import { t } from "../../i18n/index.js";
4
- /**
5
- * Handler for /model command
6
- * Shows inline menu to select model from favorites
7
- */
8
- export async function handleModelCommand(ctx) {
9
- logger.debug("[ModelCommand] /model command received");
10
- try {
11
- await showModelSelectionMenu(ctx);
12
- }
13
- catch (err) {
14
- logger.error("[ModelCommand] Error showing model menu:", err);
15
- await ctx.reply(t("error.load_models"));
16
- }
17
- }