@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.
- package/.env.example +15 -0
- package/README.md +19 -2
- package/dist/app/bootstrap/start-bot-app.js +17 -0
- package/dist/app/managers/permission-manager.js +93 -2
- package/dist/app/managers/summary-aggregation-manager.js +27 -3
- package/dist/app/services/document-extractor-service.js +47 -0
- package/dist/bot/callbacks/permission-callback-handler.js +44 -11
- package/dist/bot/handlers/document-handler.js +43 -6
- package/dist/bot/handlers/media-group-handler.js +2 -0
- package/dist/bot/handlers/message-merger.js +65 -0
- package/dist/bot/handlers/photo-handler.js +2 -0
- package/dist/bot/handlers/voice-handler.js +2 -0
- package/dist/bot/menus/permission-menu.js +34 -3
- package/dist/bot/pinned/pinned-message-manager.js +8 -6
- package/dist/bot/routers/command-router.js +7 -0
- package/dist/bot/routers/message-router.js +4 -3
- package/dist/bot/services/event-subscription-service.js +22 -3
- package/dist/config.js +19 -0
- package/dist/i18n/ar.js +3 -1
- package/dist/i18n/de.js +3 -1
- package/dist/i18n/en.js +3 -1
- package/dist/i18n/es.js +3 -1
- package/dist/i18n/fr.js +3 -1
- package/dist/i18n/ru.js +3 -1
- package/dist/i18n/zh.js +3 -1
- package/dist/opencode/events.js +8 -1
- package/dist/runtime/bootstrap.js +7 -4
- package/dist/runtime/service/manager.js +58 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
159
|
-
if (contextSize >
|
|
160
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
148
|
-
logger.debug(
|
|
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": "⚠️ نوع الملف غير مدعوم. أرسل صورة أو
|
|
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": "⚠️
|
|
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": "❌ 拒绝",
|
package/dist/opencode/events.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
297
|
+
error: errorMessage,
|
|
242
298
|
};
|
|
243
299
|
}
|
|
244
300
|
}
|