@grinev/opencode-telegram-bot 0.22.2 → 0.22.3

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.
@@ -7,6 +7,7 @@ import { config } from "../../config.js";
7
7
  import { getTtsMode } from "../../app/stores/settings-store.js";
8
8
  import { isSttConfigured, transcribeAudio, } from "../../app/services/stt-service.js";
9
9
  import { processUserPrompt } from "./prompt.js";
10
+ import { flushPendingPrompt } from "./message-merger.js";
10
11
  import { logger } from "../../utils/logger.js";
11
12
  import { t } from "../../i18n/index.js";
12
13
  import { buildTelegramFileUrl } from "../../app/services/file-download-service.js";
@@ -131,6 +132,7 @@ export async function handleVoiceMessage(ctx, deps) {
131
132
  logger.warn("[Voice] Received voice/audio message with no file_id");
132
133
  return;
133
134
  }
135
+ flushPendingPrompt(ctx.chat.id);
134
136
  // Check if STT is configured
135
137
  if (!sttConfigured()) {
136
138
  await ctx.reply(t("stt.not_configured"));
@@ -67,8 +67,31 @@ export function syncPermissionInteractionState(metadata = {}) {
67
67
  /**
68
68
  * Show permission request message with inline buttons
69
69
  */
70
- export async function showPermissionRequest(bot, chatId, request) {
70
+ export async function showPermissionRequest(bot, chatId, request, generation = permissionManager.getGeneration()) {
71
71
  logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);
72
+ if (generation !== permissionManager.getGeneration() ||
73
+ permissionManager.isResolved(request.id)) {
74
+ logger.debug(`[PermissionHandler] Skipping stale or already resolved request: ${request.id}`);
75
+ return;
76
+ }
77
+ const grouped = permissionManager.addEquivalentRequest(request, generation);
78
+ if (grouped) {
79
+ // Re-render the visible prompt so the user can see the answer will apply
80
+ // to more than one pending request.
81
+ await bot
82
+ .editMessageText(chatId, grouped.messageId, formatPermissionText(grouped.request, grouped.count), { reply_markup: buildPermissionKeyboard() })
83
+ .catch((err) => {
84
+ logger.warn("[PermissionHandler] Failed to update grouped permission message:", err);
85
+ });
86
+ syncPermissionInteractionState({
87
+ requestID: request.id,
88
+ messageId: grouped.messageId,
89
+ deduplicated: true,
90
+ groupedCount: grouped.count,
91
+ });
92
+ summaryAggregator.stopTypingIndicator();
93
+ return;
94
+ }
72
95
  const text = formatPermissionText(request);
73
96
  const keyboard = buildPermissionKeyboard();
74
97
  try {
@@ -76,7 +99,12 @@ export async function showPermissionRequest(bot, chatId, request) {
76
99
  reply_markup: keyboard,
77
100
  });
78
101
  logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
79
- permissionManager.startPermission(request, message.message_id);
102
+ if (!permissionManager.startPermission(request, message.message_id, generation)) {
103
+ await bot.deleteMessage(chatId, message.message_id).catch((err) => {
104
+ logger.warn(`[PermissionHandler] Failed to delete stale permission message:`, err);
105
+ });
106
+ return;
107
+ }
80
108
  syncPermissionInteractionState({
81
109
  requestID: request.id,
82
110
  messageId: message.message_id,
@@ -91,7 +119,7 @@ export async function showPermissionRequest(bot, chatId, request) {
91
119
  /**
92
120
  * Format permission request text
93
121
  */
94
- function formatPermissionText(request) {
122
+ function formatPermissionText(request, groupedCount = 1) {
95
123
  const emoji = PERMISSION_EMOJIS[request.permission] || "🔐";
96
124
  const nameKey = PERMISSION_NAME_KEYS[request.permission];
97
125
  const name = nameKey ? t(nameKey) : request.permission;
@@ -102,6 +130,9 @@ function formatPermissionText(request) {
102
130
  text += `• ${pattern}\n`;
103
131
  });
104
132
  }
133
+ if (groupedCount > 1) {
134
+ text += t("permission.grouped_count", { count: groupedCount });
135
+ }
105
136
  return text;
106
137
  }
107
138
  /**
@@ -137,9 +137,10 @@ class PinnedMessageManager {
137
137
  }
138
138
  return;
139
139
  }
140
- // Get the maximum context size and total cost from session history
140
+ // Get the latest measured context size and total cost from session history
141
141
  // Context = input + cache.read (cache.read contains previously cached context)
142
- let maxContextSize = 0;
142
+ let latestContextSize = 0;
143
+ let latestContextCreated = Number.NEGATIVE_INFINITY;
143
144
  let totalCost = 0;
144
145
  logger.debug(`[PinnedManager] Processing ${messagesData.length} messages from history`);
145
146
  messagesData.forEach(({ info }) => {
@@ -155,15 +156,16 @@ class PinnedMessageManager {
155
156
  const contextSize = input + cacheRead;
156
157
  const cost = assistantInfo.cost || 0;
157
158
  logger.debug(`[PinnedManager] Assistant message: input=${input}, cache.read=${cacheRead}, total=${contextSize}, cost=$${cost.toFixed(2)}`);
158
- // Keep track of maximum context size (peak usage in session)
159
- if (contextSize > maxContextSize) {
160
- maxContextSize = contextSize;
159
+ const created = assistantInfo.time?.created ?? 0;
160
+ if (contextSize > 0 && created >= latestContextCreated) {
161
+ latestContextSize = contextSize;
162
+ latestContextCreated = created;
161
163
  }
162
164
  // Accumulate total session cost
163
165
  totalCost += cost;
164
166
  }
165
167
  });
166
- this.state.tokensUsed = maxContextSize;
168
+ this.state.tokensUsed = latestContextSize;
167
169
  this.state.cost = totalCost;
168
170
  this.state.sessionId = sessionId;
169
171
  logger.info(`[PinnedManager] Loaded context from history: ${this.state.tokensUsed} tokens, cost: $${this.state.cost.toFixed(2)}`);
@@ -22,6 +22,7 @@ import { helpCommand } from "../commands/help-command.js";
22
22
  import { statusCommand } from "../commands/status-command.js";
23
23
  import { BOT_COMMANDS } from "../commands/definitions.js";
24
24
  import { logger } from "../../utils/logger.js";
25
+ import { flushPendingPrompt } from "../handlers/message-merger.js";
25
26
  let commandsInitialized = false;
26
27
  export async function ensureCommandsInitialized(ctx, next) {
27
28
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
@@ -49,6 +50,12 @@ export async function ensureCommandsInitialized(ctx, next) {
49
50
  await next();
50
51
  }
51
52
  export function registerCommandRouter(bot, deps) {
53
+ bot.use(async (ctx, next) => {
54
+ if (ctx.chat && ctx.message?.text?.startsWith("/")) {
55
+ flushPendingPrompt(ctx.chat.id);
56
+ }
57
+ await next();
58
+ });
52
59
  bot.command("start", startCommand);
53
60
  bot.command("help", helpCommand);
54
61
  bot.command("status", statusCommand);
@@ -1,3 +1,4 @@
1
+ import { config } from "../../config.js";
1
2
  import { interactionManager } from "../../app/managers/interaction-manager.js";
2
3
  import { questionManager } from "../../app/managers/question-manager.js";
3
4
  import { t } from "../../i18n/index.js";
@@ -14,7 +15,7 @@ import { AGENT_MODE_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTT
14
15
  import { handleDocumentMessage } from "../handlers/document-handler.js";
15
16
  import { createMediaGroupAttachmentMiddleware } from "../handlers/media-group-handler.js";
16
17
  import { handlePhotoMessage } from "../handlers/photo-handler.js";
17
- import { processUserPrompt } from "../handlers/prompt.js";
18
+ import { queuePromptForMerging } from "../handlers/message-merger.js";
18
19
  import { handleCatalogTextArguments } from "../handlers/text-message-handler.js";
19
20
  import { handleVoiceMessage } from "../handlers/voice-handler.js";
20
21
  import { unknownCommandMiddleware } from "../middleware/unknown-command.js";
@@ -144,7 +145,7 @@ export function registerMessageRouter(bot, deps) {
144
145
  if (handledCatalogTextArgs) {
145
146
  return;
146
147
  }
147
- await processUserPrompt(ctx, text, promptDeps);
148
- logger.debug("[Bot] message:text handler completed (prompt sent in background)");
148
+ queuePromptForMerging(ctx, text, promptDeps, config.bot.messageMergeWindowMs);
149
+ logger.debug(`[Bot] message:text handler completed (merge window=${config.bot.messageMergeWindowMs}ms)`);
149
150
  });
150
151
  }
@@ -38,9 +38,10 @@ import { deliverExternalUserInputNotification } from "../messages/external-user-
38
38
  import { backgroundSessionTracker, } from "../../app/managers/background-session-manager.js";
39
39
  import { buildBackgroundSessionOpenKeyboard } from "../menus/session-selection-menu.js";
40
40
  import { questionManager } from "../../app/managers/question-manager.js";
41
+ import { permissionManager } from "../../app/managers/permission-manager.js";
41
42
  import { showCurrentQuestion } from "../menus/question-menu.js";
42
- import { showPermissionRequest } from "../menus/permission-menu.js";
43
- import { clearAllInteractionState } from "../../app/managers/interaction-manager.js";
43
+ import { showPermissionRequest, syncPermissionInteractionState } from "../menus/permission-menu.js";
44
+ import { clearAllInteractionState, interactionManager, } from "../../app/managers/interaction-manager.js";
44
45
  import { stopEventListening, subscribeToEvents } from "../../opencode/events.js";
45
46
  const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
46
47
  const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
@@ -522,6 +523,7 @@ class EventSubscriptionService {
522
523
  clearAllInteractionState("question_error");
523
524
  });
524
525
  summaryAggregator.setOnPermission(async (request) => {
526
+ const generation = permissionManager.getGeneration();
525
527
  if (!this.botInstance || !this.chatIdInstance) {
526
528
  logger.error("Bot or chat ID not available for showing permission request");
527
529
  return;
@@ -540,7 +542,24 @@ class EventSubscriptionService {
540
542
  this.toolCallStreamer.flushSession(request.sessionID, "permission_asked"),
541
543
  ]);
542
544
  logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}, subagent=${isSubagent}`);
543
- await showPermissionRequest(this.botInstance.api, this.chatIdInstance, request);
545
+ await showPermissionRequest(this.botInstance.api, this.chatIdInstance, request, generation);
546
+ });
547
+ summaryAggregator.setOnPermissionReplied(async (_sessionId, requestID) => {
548
+ const messageIds = permissionManager.resolveRequest(requestID);
549
+ const interaction = interactionManager.getSnapshot();
550
+ if (!permissionManager.isActive() || !interaction || interaction.kind === "permission") {
551
+ syncPermissionInteractionState({ resolvedRequestID: requestID });
552
+ }
553
+ if (this.botInstance && this.chatIdInstance) {
554
+ const api = this.botInstance.api;
555
+ const chatId = this.chatIdInstance;
556
+ await Promise.all(messageIds.map((messageId) => api.deleteMessage(chatId, messageId).catch((err) => {
557
+ logger.warn(`[Bot] Failed to delete resolved permission message ${messageId}:`, err);
558
+ })));
559
+ }
560
+ if (messageIds.length > 0) {
561
+ logger.info(`[Bot] Cleared resolved permission prompt: requestID=${requestID}, messages=${messageIds.length}`);
562
+ }
544
563
  });
545
564
  summaryAggregator.setOnThinking(async (update) => {
546
565
  if (!this.botInstance || !this.chatIdInstance) {
package/dist/config.js CHANGED
@@ -21,6 +21,18 @@ function getOptionalPositiveIntEnvVar(key, defaultValue) {
21
21
  }
22
22
  return parsedValue;
23
23
  }
24
+ // Like getOptionalPositiveIntEnvVar, but also accepts 0 (used to disable a feature).
25
+ function getOptionalNonNegativeIntEnvVar(key, defaultValue) {
26
+ const value = getEnvVar(key, false);
27
+ if (!value) {
28
+ return defaultValue;
29
+ }
30
+ const parsedValue = Number.parseInt(value, 10);
31
+ if (Number.isNaN(parsedValue) || parsedValue < 0) {
32
+ return defaultValue;
33
+ }
34
+ return parsedValue;
35
+ }
24
36
  function getOptionalLocaleEnvVar(key, defaultValue) {
25
37
  const value = getEnvVar(key, false);
26
38
  return normalizeLocale(value, defaultValue);
@@ -145,6 +157,9 @@ export const config = {
145
157
  locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
146
158
  trackBackgroundSessions: getOptionalBooleanEnvVar("TRACK_BACKGROUND_SESSIONS", true),
147
159
  messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"),
160
+ // Buffer near-limit text for this window so Telegram-split chunks can be merged.
161
+ // Short messages are processed immediately; 0 disables merging entirely.
162
+ messageMergeWindowMs: getOptionalNonNegativeIntEnvVar("MESSAGE_MERGE_WINDOW_MS", 1500),
148
163
  initialSettingsPreset: parseInitialSettingsPreset(),
149
164
  },
150
165
  files: {
@@ -163,6 +178,10 @@ export const config = {
163
178
  // "json" = base64 audio in an `input_audio` JSON body (e.g. OpenRouter).
164
179
  requestFormat: getOptionalSttRequestFormatEnvVar("STT_REQUEST_FORMAT", "multipart"),
165
180
  },
181
+ docExtractor: {
182
+ apiUrl: getEnvVar("DOC_EXTRACTOR_URL", false),
183
+ apiKey: getEnvVar("DOC_EXTRACTOR_API_KEY", false),
184
+ },
166
185
  tts: (() => {
167
186
  const provider = getOptionalTtsProviderEnvVar("TTS_PROVIDER", "openai");
168
187
  const defaultVoice = provider === "google"
package/dist/i18n/ar.js CHANGED
@@ -88,10 +88,11 @@ export const ar = {
88
88
  "bot.files_downloading": "⏳ جارٍ تنزيل الملفات...",
89
89
  "bot.file_too_large": "⚠️ حجم الملف أكبر من الحد المسموح ({maxSizeMb}MB)",
90
90
  "bot.file_download_error": "🔴 تعذر تنزيل الملف",
91
- "bot.file_type_unsupported": "⚠️ نوع الملف غير مدعوم. أرسل صورة أو ملف PDF أو ملفًا نصيًا أو برمجيًا.",
91
+ "bot.file_type_unsupported": "⚠️ نوع الملف غير مدعوم. أرسل صورة أو مستندًا (PDF، DOCX، PPTX) أو ملفًا نصيًا أو برمجيًا.",
92
92
  "bot.media_group_not_processed": "⚠️ تعذر معالجة ملف أو أكثر في هذه المجموعة. لم يتم إرسال أي ملف إلى OpenCode.",
93
93
  "bot.media_group_download_error": "🔴 تعذر تنزيل أحد الملفات. لم يتم إرسال أي ملف إلى OpenCode.",
94
94
  "bot.model_no_pdf": "⚠️ النموذج الحالي لا يدعم ملفات PDF. سيتم إرسال النص فقط.",
95
+ "bot.document_extraction_error": "🔴 فشل استخراج نص المستند.",
95
96
  "bot.text_file_too_large": "⚠️ حجم الملف النصي أكبر من الحد المسموح ({maxSizeKb}KB)",
96
97
  "status.header_running": "🟢 خادم OpenCode يعمل",
97
98
  "status.health.healthy": "يعمل بشكل طبيعي",
@@ -277,6 +278,7 @@ export const ar = {
277
278
  "permission.blocked.expected_reply": "⚠️ أجب عن طلب الصلاحية أولًا باستخدام الأزرار أعلاه.",
278
279
  "permission.blocked.command_not_allowed": "⚠️ لا يمكن استخدام هذا الأمر قبل الرد على طلب الصلاحية.",
279
280
  "permission.header": "{emoji} طلب صلاحية: {name}\n\n",
281
+ "permission.grouped_count": "\n⚠️ يوجد {count} طلبات متطابقة قيد الانتظار — سيُطبَّق ردك عليها جميعًا.\n",
280
282
  "permission.button.allow": "✅ سماح لمرة واحدة",
281
283
  "permission.button.always": "🔓 سماح دائم",
282
284
  "permission.button.reject": "❌ رفض",
package/dist/i18n/de.js CHANGED
@@ -79,10 +79,11 @@ export const de = {
79
79
  "bot.files_downloading": "⏳ Lade Dateien herunter...",
80
80
  "bot.file_too_large": "⚠️ Datei ist zu groß (max. {maxSizeMb}MB)",
81
81
  "bot.file_download_error": "🔴 Datei konnte nicht heruntergeladen werden",
82
- "bot.file_type_unsupported": "⚠️ Dieser Dateityp wird nicht unterstützt. Sende ein Bild, PDF oder eine Text-/Code-Datei.",
82
+ "bot.file_type_unsupported": "⚠️ Dieser Dateityp wird nicht unterstützt. Sende ein Bild, Dokument (PDF, DOCX, PPTX) oder eine Text-/Code-Datei.",
83
83
  "bot.media_group_not_processed": "⚠️ Eine oder mehrere Dateien in diesem Album können nicht verarbeitet werden. Es wurde nichts an OpenCode gesendet.",
84
84
  "bot.media_group_download_error": "🔴 Eine der Dateien konnte nicht heruntergeladen werden. Es wurde nichts an OpenCode gesendet.",
85
85
  "bot.model_no_pdf": "⚠️ Das aktuelle Modell unterstützt keine PDF-Eingabe. Sende nur Text.",
86
+ "bot.document_extraction_error": "🔴 Dokumenttext konnte nicht extrahiert werden.",
86
87
  "bot.text_file_too_large": "⚠️ Textdatei ist zu groß (max. {maxSizeKb}KB)",
87
88
  "status.header_running": "🟢 OpenCode-Server läuft",
88
89
  "status.health.healthy": "OK",
@@ -268,6 +269,7 @@ export const de = {
268
269
  "permission.blocked.expected_reply": "⚠️ Bitte beantworte zuerst die Berechtigungsanfrage mit den Buttons oben.",
269
270
  "permission.blocked.command_not_allowed": "⚠️ Dieser Befehl ist erst verfügbar, wenn du die Berechtigungsanfrage beantwortet hast.",
270
271
  "permission.header": "{emoji} Berechtigungsanfrage: {name}\n\n",
272
+ "permission.grouped_count": "\n⚠️ {count} identische Anfragen ausstehend – deine Antwort gilt für alle.\n",
271
273
  "permission.button.allow": "✅ Einmal erlauben",
272
274
  "permission.button.always": "🔓 Immer erlauben",
273
275
  "permission.button.reject": "❌ Ablehnen",
package/dist/i18n/en.js CHANGED
@@ -79,10 +79,11 @@ export const en = {
79
79
  "bot.files_downloading": "⏳ Downloading files...",
80
80
  "bot.file_too_large": "⚠️ File is too large (max {maxSizeMb}MB)",
81
81
  "bot.file_download_error": "🔴 Failed to download file",
82
- "bot.file_type_unsupported": "⚠️ This file type is not supported. Send an image, PDF, or text/code file.",
82
+ "bot.file_type_unsupported": "⚠️ This file type is not supported. Send an image, document (PDF, DOCX, PPTX), or text/code file.",
83
83
  "bot.media_group_not_processed": "⚠️ One or more files in this album cannot be processed. Nothing was sent to OpenCode.",
84
84
  "bot.media_group_download_error": "🔴 Failed to download one of the files. Nothing was sent to OpenCode.",
85
85
  "bot.model_no_pdf": "⚠️ Current model doesn't support PDF input. Sending text only.",
86
+ "bot.document_extraction_error": "🔴 Failed to extract document text.",
86
87
  "bot.text_file_too_large": "⚠️ Text file is too large (max {maxSizeKb}KB)",
87
88
  "status.header_running": "🟢 OpenCode Server is running",
88
89
  "status.health.healthy": "Healthy",
@@ -268,6 +269,7 @@ export const en = {
268
269
  "permission.blocked.expected_reply": "⚠️ Please answer the permission request first using the buttons above.",
269
270
  "permission.blocked.command_not_allowed": "⚠️ This command is not available until you answer the permission request.",
270
271
  "permission.header": "{emoji} Permission request: {name}\n\n",
272
+ "permission.grouped_count": "\n⚠️ {count} identical requests pending — your answer applies to all of them.\n",
271
273
  "permission.button.allow": "✅ Allow once",
272
274
  "permission.button.always": "🔓 Allow always",
273
275
  "permission.button.reject": "❌ Reject",
package/dist/i18n/es.js CHANGED
@@ -79,10 +79,11 @@ export const es = {
79
79
  "bot.files_downloading": "⏳ Descargando archivos...",
80
80
  "bot.file_too_large": "⚠️ El archivo es demasiado grande (max {maxSizeMb}MB)",
81
81
  "bot.file_download_error": "🔴 No se pudo descargar el archivo",
82
- "bot.file_type_unsupported": "⚠️ Este tipo de archivo no es compatible. Envía una imagen, PDF o archivo de texto/código.",
82
+ "bot.file_type_unsupported": "⚠️ Este tipo de archivo no es compatible. Envía una imagen, documento (PDF, DOCX, PPTX) o archivo de texto/código.",
83
83
  "bot.media_group_not_processed": "⚠️ Uno o más archivos de este álbum no se pueden procesar. No se envió nada a OpenCode.",
84
84
  "bot.media_group_download_error": "🔴 No se pudo descargar uno de los archivos. No se envió nada a OpenCode.",
85
85
  "bot.model_no_pdf": "⚠️ El modelo actual no admite entrada PDF. Enviaré solo texto.",
86
+ "bot.document_extraction_error": "🔴 No se pudo extraer el texto del documento.",
86
87
  "bot.text_file_too_large": "⚠️ El archivo de texto es demasiado grande (max {maxSizeKb}KB)",
87
88
  "status.header_running": "🟢 OpenCode Server está en ejecución",
88
89
  "status.health.healthy": "Saludable",
@@ -268,6 +269,7 @@ export const es = {
268
269
  "permission.blocked.expected_reply": "⚠️ Primero responde a la solicitud de permisos usando los botones de arriba.",
269
270
  "permission.blocked.command_not_allowed": "⚠️ Este comando no está disponible hasta que respondas a la solicitud de permisos.",
270
271
  "permission.header": "{emoji} Solicitud de permisos: {name}\n\n",
272
+ "permission.grouped_count": "\n⚠️ {count} solicitudes idénticas pendientes: tu respuesta se aplica a todas.\n",
271
273
  "permission.button.allow": "✅ Permitir una vez",
272
274
  "permission.button.always": "🔓 Permitir siempre",
273
275
  "permission.button.reject": "❌ Rechazar",
package/dist/i18n/fr.js CHANGED
@@ -79,10 +79,11 @@ export const fr = {
79
79
  "bot.files_downloading": "⏳ Téléchargement des fichiers...",
80
80
  "bot.file_too_large": "⚠️ Le fichier est trop volumineux (max {maxSizeMb}MB)",
81
81
  "bot.file_download_error": "🔴 Impossible de télécharger le fichier",
82
- "bot.file_type_unsupported": "⚠️ Ce type de fichier n'est pas pris en charge. Envoyez une image, un PDF ou un fichier texte/code.",
82
+ "bot.file_type_unsupported": "⚠️ Ce type de fichier n'est pas pris en charge. Envoyez une image, un document (PDF, DOCX, PPTX) ou un fichier texte/code.",
83
83
  "bot.media_group_not_processed": "⚠️ Un ou plusieurs fichiers de cet album ne peuvent pas être traités. Rien n'a été envoyé à OpenCode.",
84
84
  "bot.media_group_download_error": "🔴 Impossible de télécharger l'un des fichiers. Rien n'a été envoyé à OpenCode.",
85
85
  "bot.model_no_pdf": "⚠️ Le modèle actuel ne prend pas en charge les PDF. Envoi du texte uniquement.",
86
+ "bot.document_extraction_error": "🔴 Échec de l'extraction du texte du document.",
86
87
  "bot.text_file_too_large": "⚠️ Le fichier texte est trop volumineux (max {maxSizeKb}KB)",
87
88
  "status.header_running": "🟢 Le serveur OpenCode est en cours d'exécution",
88
89
  "status.health.healthy": "Sain",
@@ -268,6 +269,7 @@ export const fr = {
268
269
  "permission.blocked.expected_reply": "⚠️ Veuillez d'abord répondre à la demande d'autorisation avec les boutons ci-dessus.",
269
270
  "permission.blocked.command_not_allowed": "⚠️ Cette commande n'est pas disponible tant que vous n'avez pas répondu à la demande d'autorisation.",
270
271
  "permission.header": "{emoji} Demande d'autorisation : {name}\n\n",
272
+ "permission.grouped_count": "\n⚠️ {count} demandes identiques en attente — votre réponse s'applique à toutes.\n",
271
273
  "permission.button.allow": "✅ Autoriser une fois",
272
274
  "permission.button.always": "🔓 Toujours autoriser",
273
275
  "permission.button.reject": "❌ Refuser",
package/dist/i18n/ru.js CHANGED
@@ -79,10 +79,11 @@ export const ru = {
79
79
  "bot.files_downloading": "⏳ Скачиваю файлы...",
80
80
  "bot.file_too_large": "⚠️ Файл слишком большой (макс. {maxSizeMb}МБ)",
81
81
  "bot.file_download_error": "🔴 Не удалось скачать файл",
82
- "bot.file_type_unsupported": "⚠️ Этот тип файла не поддерживается. Отправьте изображение, PDF или текстовый/кодовый файл.",
82
+ "bot.file_type_unsupported": "⚠️ Этот тип файла не поддерживается. Отправьте изображение, документ (PDF, DOCX, PPTX) или текстовый/кодовый файл.",
83
83
  "bot.media_group_not_processed": "⚠️ Один или несколько файлов в альбоме нельзя обработать. В OpenCode ничего не отправлено.",
84
84
  "bot.media_group_download_error": "🔴 Не удалось скачать один из файлов. В OpenCode ничего не отправлено.",
85
85
  "bot.model_no_pdf": "⚠️ Текущая модель не поддерживает PDF. Отправляю только текст.",
86
+ "bot.document_extraction_error": "🔴 Не удалось извлечь текст из документа.",
86
87
  "bot.text_file_too_large": "⚠️ Текстовый файл слишком большой (макс. {maxSizeKb}КБ)",
87
88
  "status.header_running": "🟢 OpenCode Server запущен",
88
89
  "status.health.healthy": "Healthy",
@@ -268,6 +269,7 @@ export const ru = {
268
269
  "permission.blocked.expected_reply": "⚠️ Сначала ответьте на запрос разрешения кнопками выше.",
269
270
  "permission.blocked.command_not_allowed": "⚠️ Эта команда недоступна, пока вы не ответите на запрос разрешения.",
270
271
  "permission.header": "{emoji} Запрос разрешения: {name}\n\n",
272
+ "permission.grouped_count": "\n⚠️ Ожидают {count} одинаковых запроса — ваш ответ применится ко всем.\n",
271
273
  "permission.button.allow": "✅ Разрешить один раз",
272
274
  "permission.button.always": "🔓 Разрешить всегда",
273
275
  "permission.button.reject": "❌ Отклонить",
package/dist/i18n/zh.js CHANGED
@@ -79,10 +79,11 @@ export const zh = {
79
79
  "bot.files_downloading": "⏳ 正在下载文件...",
80
80
  "bot.file_too_large": "⚠️ 文件过大(最大 {maxSizeMb}MB)",
81
81
  "bot.file_download_error": "🔴 下载文件失败",
82
- "bot.file_type_unsupported": "⚠️ 不支持此文件类型。请发送图片、PDF 或文本/代码文件。",
82
+ "bot.file_type_unsupported": "⚠️ 不支持此文件类型。请发送图片、文档(PDF、DOCX、PPTX)或文本/代码文件。",
83
83
  "bot.media_group_not_processed": "⚠️ 此相册中有一个或多个文件无法处理。未向 OpenCode 发送任何内容。",
84
84
  "bot.media_group_download_error": "🔴 无法下载其中一个文件。未向 OpenCode 发送任何内容。",
85
85
  "bot.model_no_pdf": "⚠️ 当前模型不支持PDF输入。将仅发送文本。",
86
+ "bot.document_extraction_error": "🔴 文档文本提取失败。",
86
87
  "bot.text_file_too_large": "⚠️ 文本文件过大(最大 {maxSizeKb}KB)",
87
88
  "status.header_running": "🟢 OpenCode 服务器正在运行",
88
89
  "status.health.healthy": "健康",
@@ -268,6 +269,7 @@ export const zh = {
268
269
  "permission.blocked.expected_reply": "⚠️ 请先使用上方按钮回答权限请求。",
269
270
  "permission.blocked.command_not_allowed": "⚠️ 在你回答权限请求之前不可用此命令。",
270
271
  "permission.header": "{emoji} 权限请求:{name}\n\n",
272
+ "permission.grouped_count": "\n⚠️ 有 {count} 个相同的请求待处理,你的回答将同时应用于全部。\n",
271
273
  "permission.button.allow": "✅ 允许一次",
272
274
  "permission.button.always": "🔓 始终允许",
273
275
  "permission.button.reject": "❌ 拒绝",
@@ -12,6 +12,7 @@ let isListening = false;
12
12
  let activeDirectory = null;
13
13
  let streamAbortController = null;
14
14
  let listenerGeneration = 0;
15
+ let consecutiveTimeouts = 0;
15
16
  function getReconnectDelayMs(attempt) {
16
17
  const exponentialDelay = RECONNECT_BASE_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
17
18
  return Math.min(exponentialDelay, RECONNECT_MAX_DELAY_MS);
@@ -179,6 +180,7 @@ export async function subscribeToEvents(directory, callback) {
179
180
  }
180
181
  }
181
182
  reconnectAttempt = 0;
183
+ consecutiveTimeouts = 0;
182
184
  eventStream = subscription.stream;
183
185
  let usefulEventCount = 0;
184
186
  try {
@@ -246,6 +248,7 @@ export async function subscribeToEvents(directory, callback) {
246
248
  continue;
247
249
  }
248
250
  reconnectAttempt++;
251
+ consecutiveTimeouts = 0;
249
252
  const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
250
253
  logger.warn(`Event stream ended for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
251
254
  const shouldContinue = await waitWithAbort(reconnectDelay, controller.signal);
@@ -265,9 +268,13 @@ export async function subscribeToEvents(directory, callback) {
265
268
  throw error;
266
269
  }
267
270
  reconnectAttempt++;
271
+ consecutiveTimeouts++;
268
272
  const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
269
273
  if (isEventStreamIdleTimeoutError(error)) {
270
- logger.warn(`Event stream idle timeout for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
274
+ const timeoutWarning = consecutiveTimeouts >= 5
275
+ ? ` (${consecutiveTimeouts} consecutive timeouts — OpenCode server may be unreachable)`
276
+ : "";
277
+ logger.warn(`Event stream idle timeout for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})${timeoutWarning}`);
271
278
  }
272
279
  else if (isExpectedOpencodeUnavailableError(error)) {
273
280
  logger.warn(`Event stream unavailable for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
@@ -408,11 +408,14 @@ function ensureInteractiveTty() {
408
408
  }
409
409
  async function validateExistingEnv(envFilePath) {
410
410
  const content = await readEnvFileIfExists(envFilePath);
411
- if (content === null) {
412
- return { isValid: false, reason: "Missing .env" };
411
+ const effectiveValues = content === null ? {} : dotenv.parse(content);
412
+ for (const key of WIZARD_ENV_KEYS) {
413
+ const processValue = process.env[key];
414
+ if (processValue !== undefined) {
415
+ effectiveValues[key] = processValue;
416
+ }
413
417
  }
414
- const parsed = dotenv.parse(content);
415
- return validateRuntimeEnvValues(parsed);
418
+ return validateRuntimeEnvValues(effectiveValues);
416
419
  }
417
420
  async function runWizardAndPersist(runtimePaths) {
418
421
  ensureInteractiveTty();
@@ -5,6 +5,7 @@ import { exec, spawn } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { getRuntimePaths } from "../paths.js";
7
7
  import { buildServiceChildEnv } from "./env.js";
8
+ import { logger } from "../../utils/logger.js";
8
9
  const execAsync = promisify(exec);
9
10
  const SERVICE_STATE_FILE_NAME = "bot-service.json";
10
11
  const PROCESS_EXIT_POLL_MS = 100;
@@ -66,6 +67,32 @@ function isProcessAlive(pid) {
66
67
  return false;
67
68
  }
68
69
  }
70
+ async function getProcessCreationTime(pid) {
71
+ try {
72
+ if (process.platform === "win32") {
73
+ const { stdout } = await execAsync(`powershell -NoProfile -Command "Get-WmiObject Win32_Process -Filter 'ProcessId=${pid}' | Select-Object -ExpandProperty CreationDate"`);
74
+ const dateStr = stdout.trim().split(/\r?\n/).find((l) => l.trim().length > 0)?.trim();
75
+ if (!dateStr) {
76
+ return null;
77
+ }
78
+ const match = dateStr.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);
79
+ if (!match) {
80
+ return null;
81
+ }
82
+ return new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}`);
83
+ }
84
+ const { stdout } = await execAsync(`ps -o lstart= -p ${pid}`);
85
+ const dateStr = stdout.trim();
86
+ if (!dateStr) {
87
+ return null;
88
+ }
89
+ const timestamp = Date.parse(dateStr);
90
+ return Number.isNaN(timestamp) ? null : new Date(timestamp);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
69
96
  async function waitForProcessExit(pid, timeoutMs) {
70
97
  const startTime = Date.now();
71
98
  while (Date.now() - startTime < timeoutMs) {
@@ -128,6 +155,22 @@ export async function getBotServiceStatus() {
128
155
  };
129
156
  }
130
157
  if (!isProcessAlive(service.pid)) {
158
+ logger.info(`[Manager] Stale daemon state cleaned up: PID=${service.pid} no longer exists`);
159
+ await clearServiceStateFile(stateFilePath);
160
+ return {
161
+ status: "stopped",
162
+ service: null,
163
+ cleanupReason: "stale",
164
+ };
165
+ }
166
+ const processCreationTime = await getProcessCreationTime(service.pid);
167
+ const storedStartedAtMs = Date.parse(service.startedAt);
168
+ if (processCreationTime &&
169
+ !Number.isNaN(storedStartedAtMs) &&
170
+ processCreationTime.getTime() > storedStartedAtMs) {
171
+ logger.warn(`[Manager] Stale daemon state detected: PID=${service.pid} exists but was created ` +
172
+ `at ${processCreationTime.toISOString()} (daemon started at ${service.startedAt}). ` +
173
+ `The original process died and the PID was reused.`);
131
174
  await clearServiceStateFile(stateFilePath);
132
175
  return {
133
176
  status: "stopped",
@@ -143,7 +186,11 @@ export async function getBotServiceStatus() {
143
186
  }
144
187
  export async function startBotDaemon(mode) {
145
188
  const currentStatus = await getBotServiceStatus();
189
+ const cleanupInfo = currentStatus.cleanupReason
190
+ ? ` (previous state: ${currentStatus.cleanupReason})`
191
+ : "";
146
192
  if (currentStatus.status === "running" && currentStatus.service) {
193
+ logger.info(`[Manager] Daemon start rejected: already running (PID=${currentStatus.service.pid})${cleanupInfo}`);
147
194
  return {
148
195
  success: false,
149
196
  service: currentStatus.service,
@@ -181,6 +228,7 @@ export async function startBotDaemon(mode) {
181
228
  mode: "daemon",
182
229
  };
183
230
  await writeFileAtomically(stateFilePath, `${JSON.stringify(serviceState, null, 2)}\n`);
231
+ logger.info(`[Manager] Daemon started: PID=${childProcess.pid}, log=${logFilePath}${cleanupInfo}`);
184
232
  return {
185
233
  success: true,
186
234
  service: serviceState,
@@ -188,12 +236,14 @@ export async function startBotDaemon(mode) {
188
236
  };
189
237
  }
190
238
  catch (error) {
239
+ const errorMessage = error instanceof Error ? error.message : String(error);
240
+ logger.error(`[Manager] Daemon start failed: ${errorMessage}${cleanupInfo}`);
191
241
  await clearServiceStateFile(stateFilePath);
192
242
  return {
193
243
  success: false,
194
244
  service: null,
195
245
  cleanupReason: currentStatus.cleanupReason,
196
- error: error instanceof Error ? error.message : String(error),
246
+ error: errorMessage,
197
247
  };
198
248
  }
199
249
  finally {
@@ -203,6 +253,7 @@ export async function startBotDaemon(mode) {
203
253
  export async function stopBotDaemon(timeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
204
254
  const currentStatus = await getBotServiceStatus();
205
255
  if (currentStatus.status !== "running" || !currentStatus.service) {
256
+ logger.info(`[Manager] Daemon stop skipped: not running (reason=${currentStatus.cleanupReason ?? "none"})`);
206
257
  return {
207
258
  success: true,
208
259
  service: null,
@@ -211,6 +262,7 @@ export async function stopBotDaemon(timeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
211
262
  };
212
263
  }
213
264
  const { pid } = currentStatus.service;
265
+ logger.info(`[Manager] Stopping daemon: PID=${pid}`);
214
266
  try {
215
267
  if (process.platform === "win32") {
216
268
  await stopWindowsProcess(pid, timeoutMs);
@@ -219,6 +271,7 @@ export async function stopBotDaemon(timeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
219
271
  await stopUnixProcess(pid, timeoutMs);
220
272
  }
221
273
  if (isProcessAlive(pid)) {
274
+ logger.warn(`[Manager] Daemon stop failed: process PID=${pid} still alive after ${timeoutMs}ms`);
222
275
  return {
223
276
  success: false,
224
277
  service: currentStatus.service,
@@ -227,6 +280,7 @@ export async function stopBotDaemon(timeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
227
280
  };
228
281
  }
229
282
  await clearServiceStateFile();
283
+ logger.info(`[Manager] Daemon stopped: PID=${pid}`);
230
284
  return {
231
285
  success: true,
232
286
  service: currentStatus.service,
@@ -234,11 +288,13 @@ export async function stopBotDaemon(timeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
234
288
  };
235
289
  }
236
290
  catch (error) {
291
+ const errorMessage = error instanceof Error ? error.message : String(error);
292
+ logger.error(`[Manager] Daemon stop error: ${errorMessage}`);
237
293
  return {
238
294
  success: false,
239
295
  service: currentStatus.service,
240
296
  cleanupReason: currentStatus.cleanupReason,
241
- error: error instanceof Error ? error.message : String(error),
297
+ error: errorMessage,
242
298
  };
243
299
  }
244
300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.22.2",
3
+ "version": "0.22.3",
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",