@grinev/opencode-telegram-bot 0.9.2 → 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
@@ -136,7 +136,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
136
136
  | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
137
137
  | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
138
138
  | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `ru`, `zh`) | No | `en` |
139
- | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
139
+ | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
140
140
  | `PROJECTS_LIST_LIMIT` | Max projects shown in `/projects` | No | `10` |
141
141
  | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
142
142
  | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
@@ -176,15 +176,14 @@ If STT variables are not set, voice/audio transcription is disabled and the bot
176
176
 
177
177
  ### Model Configuration
178
178
 
179
- 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`):
180
180
 
181
- 1. Open OpenCode TUI (`opencode`)
182
- 2. Go to model selection
183
- 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
184
185
 
185
- These favorites will appear in the model picker opened from the bottom Telegram keyboard.
186
-
187
- 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.
188
187
 
189
188
  ## Security
190
189
 
@@ -240,6 +239,7 @@ npm run dev
240
239
  **No models in model picker**
241
240
 
242
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
243
243
 
244
244
  **Linux: permission denied errors**
245
245
 
@@ -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,
@@ -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/i18n/de.js CHANGED
@@ -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
@@ -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
@@ -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
@@ -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,8 +124,13 @@ 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
136
  "variant.changed_callback": "Вариант изменен: {name}",
package/dist/i18n/zh.js CHANGED
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.9.2",
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",