@grinev/opencode-telegram-bot 0.20.3 → 0.20.4
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.
|
@@ -12,6 +12,7 @@ import { getRuntimePaths } from "../runtime/paths.js";
|
|
|
12
12
|
import { clearServiceStateFile } from "../service/manager.js";
|
|
13
13
|
import { getServiceStateFilePathFromEnv, isServiceChildProcess } from "../service/runtime.js";
|
|
14
14
|
import { getLogFilePath, initializeLogger, logger } from "../utils/logger.js";
|
|
15
|
+
import { safeBackgroundTask } from "../utils/safe-background-task.js";
|
|
15
16
|
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
16
17
|
async function getBotVersion() {
|
|
17
18
|
try {
|
|
@@ -43,8 +44,13 @@ export async function startBotApp() {
|
|
|
43
44
|
registerOpenCodeReadyRefreshHandler();
|
|
44
45
|
const bot = createBot();
|
|
45
46
|
await scheduledTaskRuntime.initialize(bot);
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
safeBackgroundTask({
|
|
48
|
+
taskName: "app.opencodeStartup",
|
|
49
|
+
task: async () => {
|
|
50
|
+
await opencodeAutoRestartService.start();
|
|
51
|
+
await notifyOpencodeReadyIfHealthy("startup");
|
|
52
|
+
},
|
|
53
|
+
});
|
|
48
54
|
let shutdownStarted = false;
|
|
49
55
|
let serviceStateCleared = false;
|
|
50
56
|
let shutdownTimeout = null;
|
|
@@ -8,7 +8,11 @@ import { interactionManager } from "../../interaction/manager.js";
|
|
|
8
8
|
import { logger } from "../../utils/logger.js";
|
|
9
9
|
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
10
10
|
import { t } from "../../i18n/index.js";
|
|
11
|
+
import { editRenderedBotPart, sendRenderedBotPart } from "../utils/telegram-text.js";
|
|
11
12
|
const MAX_BUTTON_LENGTH = 60;
|
|
13
|
+
const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
14
|
+
const TRUNCATION_SUFFIX = "…";
|
|
15
|
+
const QUESTION_EMOJI = "❓";
|
|
12
16
|
function getCallbackMessageId(ctx) {
|
|
13
17
|
const message = ctx.callbackQuery?.message;
|
|
14
18
|
if (!message || !("message_id" in message)) {
|
|
@@ -186,12 +190,26 @@ async function updateQuestionMessage(ctx) {
|
|
|
186
190
|
logger.debug("[QuestionHandler] updateQuestionMessage: no current question");
|
|
187
191
|
return;
|
|
188
192
|
}
|
|
189
|
-
const
|
|
193
|
+
const part = formatQuestionDetailsPart(question);
|
|
190
194
|
const keyboard = buildQuestionKeyboard(question, questionManager.getSelectedOptions(questionManager.getCurrentIndex()));
|
|
191
195
|
logger.debug("[QuestionHandler] Updating question message");
|
|
192
196
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
197
|
+
const chatId = ctx.chat?.id;
|
|
198
|
+
const messageId = getCallbackMessageId(ctx);
|
|
199
|
+
if (!chatId || messageId === null) {
|
|
200
|
+
await ctx.editMessageText(part.fallbackText, {
|
|
201
|
+
reply_markup: keyboard,
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await editRenderedBotPart({
|
|
206
|
+
api: ctx.api,
|
|
207
|
+
chatId,
|
|
208
|
+
messageId,
|
|
209
|
+
part,
|
|
210
|
+
options: {
|
|
211
|
+
reply_markup: keyboard,
|
|
212
|
+
},
|
|
195
213
|
});
|
|
196
214
|
}
|
|
197
215
|
catch (err) {
|
|
@@ -205,16 +223,21 @@ export async function showCurrentQuestion(bot, chatId) {
|
|
|
205
223
|
return;
|
|
206
224
|
}
|
|
207
225
|
logger.debug(`[QuestionHandler] Showing question: ${question.header} - ${question.question}`);
|
|
208
|
-
const
|
|
226
|
+
const part = formatQuestionDetailsPart(question);
|
|
209
227
|
const keyboard = buildQuestionKeyboard(question, questionManager.getSelectedOptions(questionManager.getCurrentIndex()));
|
|
210
228
|
logger.debug(`[QuestionHandler] Sending message with keyboard, chatId=${chatId}`);
|
|
211
229
|
try {
|
|
212
|
-
const
|
|
213
|
-
|
|
230
|
+
const { messageId } = await sendRenderedBotPart({
|
|
231
|
+
api: bot,
|
|
232
|
+
chatId,
|
|
233
|
+
part,
|
|
234
|
+
options: {
|
|
235
|
+
reply_markup: keyboard,
|
|
236
|
+
},
|
|
214
237
|
});
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
questionManager.setActiveMessageId(
|
|
238
|
+
questionManager.addMessageId(messageId);
|
|
239
|
+
logger.debug(`[QuestionHandler] Message sent, messageId=${messageId}`);
|
|
240
|
+
questionManager.setActiveMessageId(messageId);
|
|
218
241
|
syncQuestionInteractionState("callback", questionManager.getCurrentIndex(), questionManager.getActiveMessageId());
|
|
219
242
|
summaryAggregator.stopTypingIndicator();
|
|
220
243
|
}
|
|
@@ -335,14 +358,67 @@ async function sendAllAnswersToAgent(bot, chatId) {
|
|
|
335
358
|
},
|
|
336
359
|
});
|
|
337
360
|
}
|
|
338
|
-
function
|
|
361
|
+
function formatQuestionDetailsPart(question) {
|
|
339
362
|
const currentIndex = questionManager.getCurrentIndex();
|
|
340
363
|
const totalQuestions = questionManager.getTotalQuestions();
|
|
341
364
|
const progressText = totalQuestions > 0 ? `${currentIndex + 1}/${totalQuestions}` : "";
|
|
342
|
-
const headerTitle = [progressText, question.header].filter(Boolean).join(" ");
|
|
343
|
-
const
|
|
365
|
+
const headerTitle = [QUESTION_EMOJI, progressText, question.header].filter(Boolean).join(" ");
|
|
366
|
+
const textParts = [];
|
|
367
|
+
const entities = [];
|
|
368
|
+
if (headerTitle) {
|
|
369
|
+
textParts.push(headerTitle);
|
|
370
|
+
entities.push({ type: "bold", offset: 0, length: headerTitle.length });
|
|
371
|
+
}
|
|
344
372
|
const multiple = question.multiple ? t("question.multi_hint") : "";
|
|
345
|
-
|
|
373
|
+
const questionText = `${question.question}${multiple}`;
|
|
374
|
+
if (questionText) {
|
|
375
|
+
textParts.push(questionText);
|
|
376
|
+
}
|
|
377
|
+
for (const option of question.options) {
|
|
378
|
+
const optionText = formatOptionDetails(option);
|
|
379
|
+
const offset = textParts.join("\n\n").length + (textParts.length > 0 ? 2 : 0);
|
|
380
|
+
if (option.label) {
|
|
381
|
+
entities.push({ type: "bold", offset, length: option.label.length });
|
|
382
|
+
}
|
|
383
|
+
textParts.push(optionText);
|
|
384
|
+
}
|
|
385
|
+
const text = textParts.filter(Boolean).join("\n\n");
|
|
386
|
+
const truncated = truncateQuestionPart(text, entities);
|
|
387
|
+
return {
|
|
388
|
+
text: truncated.text,
|
|
389
|
+
entities: truncated.entities.length > 0 ? truncated.entities : undefined,
|
|
390
|
+
fallbackText: truncated.text,
|
|
391
|
+
source: truncated.entities.length > 0 ? "entities" : "plain",
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function truncateQuestionPart(text, entities) {
|
|
395
|
+
if (text.length <= TELEGRAM_MESSAGE_LIMIT) {
|
|
396
|
+
return { text, entities };
|
|
397
|
+
}
|
|
398
|
+
const maxBaseLength = TELEGRAM_MESSAGE_LIMIT - TRUNCATION_SUFFIX.length;
|
|
399
|
+
let endIndex = maxBaseLength;
|
|
400
|
+
if (endIndex > 0 && isHighSurrogate(text.charCodeAt(endIndex - 1))) {
|
|
401
|
+
endIndex -= 1;
|
|
402
|
+
}
|
|
403
|
+
const truncatedText = `${text.slice(0, endIndex)}${TRUNCATION_SUFFIX}`;
|
|
404
|
+
const truncatedEntities = entities
|
|
405
|
+
.filter((entity) => entity.offset < endIndex)
|
|
406
|
+
.map((entity) => ({
|
|
407
|
+
...entity,
|
|
408
|
+
length: Math.min(entity.length, endIndex - entity.offset),
|
|
409
|
+
}))
|
|
410
|
+
.filter((entity) => entity.length > 0);
|
|
411
|
+
return { text: truncatedText, entities: truncatedEntities };
|
|
412
|
+
}
|
|
413
|
+
function isHighSurrogate(codeUnit) {
|
|
414
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
415
|
+
}
|
|
416
|
+
function formatOptionDetails(option) {
|
|
417
|
+
const optionTitle = option.label;
|
|
418
|
+
if (!option.description) {
|
|
419
|
+
return optionTitle;
|
|
420
|
+
}
|
|
421
|
+
return `${optionTitle} — ${option.description}`;
|
|
346
422
|
}
|
|
347
423
|
function buildQuestionKeyboard(question, selectedOptions) {
|
|
348
424
|
const keyboard = new InlineKeyboard();
|
|
@@ -351,7 +427,7 @@ function buildQuestionKeyboard(question, selectedOptions) {
|
|
|
351
427
|
question.options.forEach((option, index) => {
|
|
352
428
|
const isSelected = selectedOptions.has(index);
|
|
353
429
|
const icon = isSelected ? "✅ " : "";
|
|
354
|
-
const buttonText = formatButtonText(option.label,
|
|
430
|
+
const buttonText = formatButtonText(option.label, icon);
|
|
355
431
|
const callbackData = `question:select:${questionIndex}:${index}`;
|
|
356
432
|
logger.debug(`[QuestionHandler] Button ${index}: "${buttonText}" -> "${callbackData}"`);
|
|
357
433
|
keyboard.text(buttonText, callbackData).row();
|
|
@@ -367,11 +443,8 @@ function buildQuestionKeyboard(question, selectedOptions) {
|
|
|
367
443
|
logger.debug(`[QuestionHandler] Final keyboard: ${JSON.stringify(keyboard.inline_keyboard)}`);
|
|
368
444
|
return keyboard;
|
|
369
445
|
}
|
|
370
|
-
function formatButtonText(label,
|
|
446
|
+
function formatButtonText(label, icon) {
|
|
371
447
|
let text = `${icon}${label}`;
|
|
372
|
-
if (description && icon === "") {
|
|
373
|
-
text += ` - ${description}`;
|
|
374
|
-
}
|
|
375
448
|
if (text.length > MAX_BUTTON_LENGTH) {
|
|
376
449
|
text = text.substring(0, MAX_BUTTON_LENGTH - 3) + "...";
|
|
377
450
|
}
|
|
@@ -8,6 +8,7 @@ import { isSttConfigured, transcribeAudio } from "../../stt/client.js";
|
|
|
8
8
|
import { processUserPrompt } from "./prompt.js";
|
|
9
9
|
import { logger } from "../../utils/logger.js";
|
|
10
10
|
import { t } from "../../i18n/index.js";
|
|
11
|
+
import { buildTelegramFileUrl } from "../utils/telegram-file-url.js";
|
|
11
12
|
const TELEGRAM_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
12
13
|
const TELEGRAM_DOWNLOAD_MAX_REDIRECTS = 3;
|
|
13
14
|
let telegramDownloadAgent;
|
|
@@ -80,8 +81,7 @@ async function downloadTelegramFile(ctx, fileId) {
|
|
|
80
81
|
logger.error("[Voice] Telegram getFile returned no file_path");
|
|
81
82
|
return null;
|
|
82
83
|
}
|
|
83
|
-
const
|
|
84
|
-
const fileUrl = `${apiRoot}/file/bot${ctx.api.token}/${file.file_path}`;
|
|
84
|
+
const fileUrl = buildTelegramFileUrl(file.file_path);
|
|
85
85
|
logger.debug(`[Voice] Downloading file: ${file.file_path} (${file.file_size ?? "?"} bytes)`);
|
|
86
86
|
const buffer = await downloadTelegramFileByUrl(fileUrl);
|
|
87
87
|
// Extract filename from file_path (e.g., "voice/file_123.oga" -> "file_123.oga")
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
// @ts-expect-error — node-fetch v2 ships no TS types and we avoid adding @types/node-fetch
|
|
2
2
|
import nodeFetch from "node-fetch";
|
|
3
3
|
import { Agent as HttpsAgent } from "https";
|
|
4
|
-
import { config } from "../../config.js";
|
|
5
4
|
import { logger } from "../../utils/logger.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return config.telegram.apiRoot
|
|
9
|
-
? `${config.telegram.apiRoot.replace(/\/$/, "")}/file/bot`
|
|
10
|
-
: DEFAULT_TELEGRAM_FILE_URL_BASE;
|
|
11
|
-
}
|
|
5
|
+
import { config } from "../../config.js";
|
|
6
|
+
import { buildTelegramFileUrl } from "./telegram-file-url.js";
|
|
12
7
|
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB Telegram limit
|
|
13
8
|
/**
|
|
14
9
|
* Download a photo from Telegram servers
|
|
@@ -26,7 +21,7 @@ export async function downloadTelegramFile(api, fileId) {
|
|
|
26
21
|
const sizeMb = (file.file_size / (1024 * 1024)).toFixed(2);
|
|
27
22
|
throw new Error(`File too large: ${sizeMb}MB (max 20MB)`);
|
|
28
23
|
}
|
|
29
|
-
const fileUrl =
|
|
24
|
+
const fileUrl = buildTelegramFileUrl(file.file_path);
|
|
30
25
|
logger.debug(`[FileDownload] Downloading from ${fileUrl.replace(config.telegram.token, "***")}`);
|
|
31
26
|
const fetchOptions = {};
|
|
32
27
|
// Use proxy if configured
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { config } from "../../config.js";
|
|
2
|
+
const DEFAULT_TELEGRAM_API_ROOT = "https://api.telegram.org";
|
|
3
|
+
export function telegramFileUrlBase() {
|
|
4
|
+
const apiRoot = config.telegram.apiRoot
|
|
5
|
+
? config.telegram.apiRoot.replace(/\/+$/, "")
|
|
6
|
+
: DEFAULT_TELEGRAM_API_ROOT;
|
|
7
|
+
return `${apiRoot}/file/bot`;
|
|
8
|
+
}
|
|
9
|
+
export function buildTelegramFileUrl(filePath) {
|
|
10
|
+
return `${telegramFileUrlBase()}${config.telegram.token}/${filePath}`;
|
|
11
|
+
}
|
|
@@ -5,12 +5,35 @@ import { opencodeReadyLifecycle } from "./ready-lifecycle.js";
|
|
|
5
5
|
import { resolveLocalOpencodeTarget, startLocalOpencodeServer, } from "./process.js";
|
|
6
6
|
const SERVER_READY_TIMEOUT_MS = 10000;
|
|
7
7
|
const SERVER_READY_POLL_INTERVAL_MS = 500;
|
|
8
|
+
const HEALTH_CHECK_TIMEOUT_MS = 3000;
|
|
9
|
+
const HEALTH_CHECK_TIMED_OUT = Symbol("health-check-timed-out");
|
|
8
10
|
function sleep(ms) {
|
|
9
11
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
12
|
}
|
|
13
|
+
async function withTimeout(promise, timeoutMs) {
|
|
14
|
+
let timeout;
|
|
15
|
+
try {
|
|
16
|
+
return await Promise.race([
|
|
17
|
+
promise,
|
|
18
|
+
new Promise((resolve) => {
|
|
19
|
+
timeout = setTimeout(() => resolve(HEALTH_CHECK_TIMED_OUT), timeoutMs);
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
if (timeout) {
|
|
25
|
+
clearTimeout(timeout);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
11
29
|
async function isOpencodeServerHealthy() {
|
|
12
30
|
try {
|
|
13
|
-
const
|
|
31
|
+
const result = await withTimeout(opencodeClient.global.health(), HEALTH_CHECK_TIMEOUT_MS);
|
|
32
|
+
if (result === HEALTH_CHECK_TIMED_OUT) {
|
|
33
|
+
logger.warn(`[OpenCodeAutoRestart] Health-check timed out after ${HEALTH_CHECK_TIMEOUT_MS}ms`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const { data, error } = result;
|
|
14
37
|
return !error && data?.healthy === true;
|
|
15
38
|
}
|
|
16
39
|
catch {
|