@grinev/opencode-telegram-bot 0.20.2 → 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.
- package/.env.example +15 -0
- package/README.md +51 -0
- package/dist/app/start-bot-app.js +8 -2
- package/dist/bot/handlers/question.js +91 -18
- package/dist/bot/handlers/voice.js +7 -2
- package/dist/bot/index.js +2 -21
- package/dist/bot/telegram-client-options.js +63 -0
- package/dist/bot/utils/file-download.js +17 -4
- package/dist/bot/utils/telegram-file-url.js +11 -0
- package/dist/config.js +25 -4
- package/dist/opencode/auto-restart.js +24 -1
- package/package.json +2 -1
package/.env.example
CHANGED
|
@@ -12,6 +12,21 @@ TELEGRAM_ALLOWED_USER_ID=
|
|
|
12
12
|
# TELEGRAM_PROXY_URL=http://proxy.example.com:8080
|
|
13
13
|
# TELEGRAM_PROXY_URL=
|
|
14
14
|
|
|
15
|
+
# Telegram Reverse-Proxy mode (optional, alternative to TELEGRAM_PROXY_URL)
|
|
16
|
+
# For corporate networks that block api.telegram.org but allow your own server.
|
|
17
|
+
# Point TELEGRAM_API_ROOT at an HTTPS endpoint that reverse-proxies to
|
|
18
|
+
# https://api.telegram.org (e.g. nginx). Applied to Bot API calls AND file
|
|
19
|
+
# downloads. TELEGRAM_PROXY_SECRET (optional) is sent as the X-Proxy-Secret
|
|
20
|
+
# header so the reverse proxy can authorize callers. See README for an example
|
|
21
|
+
# nginx config.
|
|
22
|
+
# TELEGRAM_API_ROOT=https://tg-proxy.yourdomain.com
|
|
23
|
+
# TELEGRAM_PROXY_SECRET=some-long-random-string
|
|
24
|
+
|
|
25
|
+
# Force IPv4 for direct Telegram API/file requests (optional, default: false)
|
|
26
|
+
# Enable this if startup fails with "Network request ... failed" in an
|
|
27
|
+
# environment where IPv6 DNS exists but outbound IPv6 connectivity is broken.
|
|
28
|
+
# TELEGRAM_FORCE_IPV4=false
|
|
29
|
+
|
|
15
30
|
# OpenCode API URL (optional, default: http://localhost:4096)
|
|
16
31
|
# OPENCODE_API_URL=http://localhost:4096
|
|
17
32
|
|
package/README.md
CHANGED
|
@@ -198,6 +198,9 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
198
198
|
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
|
|
199
199
|
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
|
|
200
200
|
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
|
|
201
|
+
| `TELEGRAM_API_ROOT` | Custom Telegram Bot API root URL (e.g. nginx reverse-proxying `api.telegram.org`); applied to API calls and file downloads | No | `https://api.telegram.org` |
|
|
202
|
+
| `TELEGRAM_PROXY_SECRET` | Shared secret sent as `X-Proxy-Secret` header on every Bot API request and file download (used with `TELEGRAM_API_ROOT`) | No | — |
|
|
203
|
+
| `TELEGRAM_FORCE_IPV4` | Force IPv4 for direct Telegram API and file requests; useful when IPv6 DNS works but outbound IPv6 is broken | No | `false` |
|
|
201
204
|
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
|
|
202
205
|
| `OPENCODE_AUTO_RESTART_ENABLED` | Automatically restart a local OpenCode server when health-checks fail | No | `false` |
|
|
203
206
|
| `OPENCODE_MONITOR_INTERVAL_SEC` | Health monitor interval in seconds when OpenCode auto-restart is enabled | No | `300` |
|
|
@@ -239,6 +242,54 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
239
242
|
|
|
240
243
|
Logs are written to `./logs` when running from sources and to the runtime config directory `logs/` folder in `installed` mode. Log rotation depends on runtime mode: `sources` creates one file per bot launch, while `installed` appends to one file per day. Old log files are removed according to `LOG_RETENTION`.
|
|
241
244
|
|
|
245
|
+
### Reverse Proxy (Optional)
|
|
246
|
+
|
|
247
|
+
For environments that block `api.telegram.org` but allow your own HTTPS endpoint (corporate networks, restricted regions), you can route Bot API traffic through a reverse proxy you control. This is an alternative to the SOCKS/HTTP forward proxy configured with `TELEGRAM_PROXY_URL`.
|
|
248
|
+
|
|
249
|
+
Set `TELEGRAM_API_ROOT` to your reverse-proxy URL — both Bot API calls and file downloads (including voice/audio files) will use it. Optionally set `TELEGRAM_PROXY_SECRET` so the bot sends an `X-Proxy-Secret` header your proxy can use to authorize callers.
|
|
250
|
+
|
|
251
|
+
`.env`:
|
|
252
|
+
|
|
253
|
+
```env
|
|
254
|
+
TELEGRAM_API_ROOT=https://tg-proxy.yourdomain.com
|
|
255
|
+
TELEGRAM_PROXY_SECRET=some-long-random-string
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Example nginx config:
|
|
259
|
+
|
|
260
|
+
```nginx
|
|
261
|
+
server {
|
|
262
|
+
listen 443 ssl http2;
|
|
263
|
+
server_name tg-proxy.yourdomain.com;
|
|
264
|
+
|
|
265
|
+
ssl_certificate /etc/letsencrypt/live/tg-proxy.yourdomain.com/fullchain.pem;
|
|
266
|
+
ssl_certificate_key /etc/letsencrypt/live/tg-proxy.yourdomain.com/privkey.pem;
|
|
267
|
+
|
|
268
|
+
access_log off; # the bot token appears in URL paths
|
|
269
|
+
client_max_body_size 50m;
|
|
270
|
+
|
|
271
|
+
if ($http_x_proxy_secret != "some-long-random-string") { return 403; }
|
|
272
|
+
|
|
273
|
+
location / {
|
|
274
|
+
proxy_pass https://api.telegram.org;
|
|
275
|
+
proxy_ssl_server_name on;
|
|
276
|
+
proxy_set_header Host api.telegram.org;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`TELEGRAM_API_ROOT` and `TELEGRAM_PROXY_URL` are alternative connectivity modes — the former picks the URL the bot connects to (a reverse proxy on your side), while the latter tunnels TCP through a forward proxy. Configure only one of them; the bot rejects using both at startup.
|
|
282
|
+
|
|
283
|
+
### Force IPv4 for Telegram (Optional)
|
|
284
|
+
|
|
285
|
+
If the bot fails during startup with errors such as `Network request for 'setMyCommands' failed` or `Network request for 'getWebhookInfo' failed`, and the same machine has broken outbound IPv6 connectivity, force direct Telegram requests to use IPv4:
|
|
286
|
+
|
|
287
|
+
```env
|
|
288
|
+
TELEGRAM_FORCE_IPV4=true
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
This affects direct Bot API calls and Telegram file downloads. It is not a replacement for `TELEGRAM_PROXY_URL` or `TELEGRAM_API_ROOT` when Telegram is blocked by the network.
|
|
292
|
+
|
|
242
293
|
### Voice and Audio Transcription (Optional)
|
|
243
294
|
|
|
244
295
|
If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
|
|
@@ -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;
|
|
@@ -30,7 +31,11 @@ async function downloadTelegramFileByUrl(url, redirectDepth = 0) {
|
|
|
30
31
|
return new Promise((resolve, reject) => {
|
|
31
32
|
const targetUrl = new URL(url);
|
|
32
33
|
const requestModule = targetUrl.protocol === "http:" ? http : https;
|
|
33
|
-
const
|
|
34
|
+
const proxySecret = config.telegram.proxySecret;
|
|
35
|
+
const request = requestModule.get(targetUrl, {
|
|
36
|
+
agent: getTelegramDownloadAgent(),
|
|
37
|
+
...(proxySecret ? { headers: { "X-Proxy-Secret": proxySecret } } : {}),
|
|
38
|
+
}, (response) => {
|
|
34
39
|
const statusCode = response.statusCode ?? 0;
|
|
35
40
|
if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
|
|
36
41
|
response.resume();
|
|
@@ -76,7 +81,7 @@ async function downloadTelegramFile(ctx, fileId) {
|
|
|
76
81
|
logger.error("[Voice] Telegram getFile returned no file_path");
|
|
77
82
|
return null;
|
|
78
83
|
}
|
|
79
|
-
const fileUrl =
|
|
84
|
+
const fileUrl = buildTelegramFileUrl(file.file_path);
|
|
80
85
|
logger.debug(`[Voice] Downloading file: ${file.file_path} (${file.file_size ?? "?"} bytes)`);
|
|
81
86
|
const buffer = await downloadTelegramFileByUrl(fileUrl);
|
|
82
87
|
// Extract filename from file_path (e.g., "voice/file_123.oga" -> "file_123.oga")
|
package/dist/bot/index.js
CHANGED
|
@@ -2,8 +2,6 @@ import { Bot, InputFile } from "grammy";
|
|
|
2
2
|
import { promises as fs } from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
-
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
6
|
-
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
7
5
|
import { config } from "../config.js";
|
|
8
6
|
import { authMiddleware } from "./middleware/auth.js";
|
|
9
7
|
import { interactionGuardMiddleware } from "./middleware/interaction-guard.js";
|
|
@@ -55,6 +53,7 @@ import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.j
|
|
|
55
53
|
import { pinnedMessageManager } from "../pinned/manager.js";
|
|
56
54
|
import { t } from "../i18n/index.js";
|
|
57
55
|
import { getCurrentProject } from "../settings/manager.js";
|
|
56
|
+
import { createTelegramBotOptions } from "./telegram-client-options.js";
|
|
58
57
|
import { clearPromptResponseMode, processUserPrompt } from "./handlers/prompt.js";
|
|
59
58
|
import { handleVoiceMessage } from "./handlers/voice.js";
|
|
60
59
|
import { handleDocumentMessage } from "./handlers/document.js";
|
|
@@ -838,25 +837,7 @@ export function createBot() {
|
|
|
838
837
|
clearInterval(heartbeatTimer);
|
|
839
838
|
heartbeatTimer = null;
|
|
840
839
|
}
|
|
841
|
-
const botOptions =
|
|
842
|
-
if (config.telegram.proxyUrl) {
|
|
843
|
-
const proxyUrl = config.telegram.proxyUrl;
|
|
844
|
-
let agent;
|
|
845
|
-
if (proxyUrl.startsWith("socks")) {
|
|
846
|
-
agent = new SocksProxyAgent(proxyUrl);
|
|
847
|
-
logger.info(`[Bot] Using SOCKS proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
|
|
848
|
-
}
|
|
849
|
-
else {
|
|
850
|
-
agent = new HttpsProxyAgent(proxyUrl);
|
|
851
|
-
logger.info(`[Bot] Using HTTP/HTTPS proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
|
|
852
|
-
}
|
|
853
|
-
botOptions.client = {
|
|
854
|
-
baseFetchConfig: {
|
|
855
|
-
agent,
|
|
856
|
-
compress: true,
|
|
857
|
-
},
|
|
858
|
-
};
|
|
859
|
-
}
|
|
840
|
+
const botOptions = createTelegramBotOptions(config.telegram);
|
|
860
841
|
const bot = new Bot(config.telegram.token, botOptions);
|
|
861
842
|
botInstance = bot;
|
|
862
843
|
chatIdInstance = config.telegram.allowedUserId;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @ts-expect-error — node-fetch v2 ships no TS types and we avoid adding @types/node-fetch
|
|
2
|
+
import nodeFetch from "node-fetch";
|
|
3
|
+
import { Agent as HttpsAgent } from "https";
|
|
4
|
+
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
5
|
+
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
6
|
+
import { logger } from "../utils/logger.js";
|
|
7
|
+
export function createTelegramIpv4Agent() {
|
|
8
|
+
return new HttpsAgent({ family: 4, keepAlive: true });
|
|
9
|
+
}
|
|
10
|
+
export function createTelegramBotOptions(telegram) {
|
|
11
|
+
const botOptions = {};
|
|
12
|
+
if (telegram.apiRoot || telegram.proxySecret) {
|
|
13
|
+
botOptions.client = botOptions.client ?? {};
|
|
14
|
+
if (telegram.apiRoot) {
|
|
15
|
+
botOptions.client.apiRoot = telegram.apiRoot;
|
|
16
|
+
logger.info(`[Bot] Using custom Telegram API root: ${telegram.apiRoot}`);
|
|
17
|
+
}
|
|
18
|
+
if (telegram.proxySecret) {
|
|
19
|
+
// Inject the shared-secret header via a custom fetch wrapper instead of
|
|
20
|
+
// baseFetchConfig.headers, because grammY's client spreads
|
|
21
|
+
// `{...baseFetchConfig, ...config}` and the per-request config.headers
|
|
22
|
+
// (Content-Type/Length) wipes out anything we put on baseFetchConfig.
|
|
23
|
+
// Plain-object headers merge (not the Headers class) keeps this compatible
|
|
24
|
+
// with node-fetch v2's init shape and avoids the DOM lib HeadersInit type.
|
|
25
|
+
const proxySecret = telegram.proxySecret;
|
|
26
|
+
botOptions.client.fetch = ((url, init) => {
|
|
27
|
+
const existing = init?.headers ?? {};
|
|
28
|
+
const merged = { ...existing, "X-Proxy-Secret": proxySecret };
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
return nodeFetch(url, { ...(init ?? {}), headers: merged });
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
});
|
|
33
|
+
logger.info(`[Bot] Sending X-Proxy-Secret header to Telegram API root`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (telegram.proxyUrl) {
|
|
37
|
+
const proxyUrl = telegram.proxyUrl;
|
|
38
|
+
let agent;
|
|
39
|
+
if (proxyUrl.startsWith("socks")) {
|
|
40
|
+
agent = new SocksProxyAgent(proxyUrl);
|
|
41
|
+
logger.info(`[Bot] Using SOCKS proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
agent = new HttpsProxyAgent(proxyUrl);
|
|
45
|
+
logger.info(`[Bot] Using HTTP/HTTPS proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
|
|
46
|
+
}
|
|
47
|
+
botOptions.client = botOptions.client ?? {};
|
|
48
|
+
botOptions.client.baseFetchConfig = {
|
|
49
|
+
agent,
|
|
50
|
+
compress: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
else if (telegram.forceIpv4) {
|
|
54
|
+
botOptions.client = botOptions.client ?? {};
|
|
55
|
+
botOptions.client.baseFetchConfig = {
|
|
56
|
+
...(botOptions.client.baseFetchConfig ?? {}),
|
|
57
|
+
agent: createTelegramIpv4Agent(),
|
|
58
|
+
compress: true,
|
|
59
|
+
};
|
|
60
|
+
logger.info(`[Bot] Forcing IPv4 for Telegram API requests`);
|
|
61
|
+
}
|
|
62
|
+
return botOptions;
|
|
63
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
// @ts-expect-error — node-fetch v2 ships no TS types and we avoid adding @types/node-fetch
|
|
2
|
+
import nodeFetch from "node-fetch";
|
|
3
|
+
import { Agent as HttpsAgent } from "https";
|
|
2
4
|
import { logger } from "../../utils/logger.js";
|
|
3
|
-
|
|
5
|
+
import { config } from "../../config.js";
|
|
6
|
+
import { buildTelegramFileUrl } from "./telegram-file-url.js";
|
|
4
7
|
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB Telegram limit
|
|
5
8
|
/**
|
|
6
9
|
* Download a photo from Telegram servers
|
|
@@ -18,7 +21,7 @@ export async function downloadTelegramFile(api, fileId) {
|
|
|
18
21
|
const sizeMb = (file.file_size / (1024 * 1024)).toFixed(2);
|
|
19
22
|
throw new Error(`File too large: ${sizeMb}MB (max 20MB)`);
|
|
20
23
|
}
|
|
21
|
-
const fileUrl =
|
|
24
|
+
const fileUrl = buildTelegramFileUrl(file.file_path);
|
|
22
25
|
logger.debug(`[FileDownload] Downloading from ${fileUrl.replace(config.telegram.token, "***")}`);
|
|
23
26
|
const fetchOptions = {};
|
|
24
27
|
// Use proxy if configured
|
|
@@ -26,7 +29,17 @@ export async function downloadTelegramFile(api, fileId) {
|
|
|
26
29
|
const { HttpsProxyAgent } = await import("https-proxy-agent");
|
|
27
30
|
fetchOptions.agent = new HttpsProxyAgent(config.telegram.proxyUrl);
|
|
28
31
|
}
|
|
29
|
-
|
|
32
|
+
else if (config.telegram.forceIpv4) {
|
|
33
|
+
fetchOptions.agent = new HttpsAgent({ family: 4, keepAlive: true });
|
|
34
|
+
}
|
|
35
|
+
// Send shared secret when custom API root expects it
|
|
36
|
+
if (config.telegram.proxySecret) {
|
|
37
|
+
fetchOptions.headers = {
|
|
38
|
+
...fetchOptions.headers,
|
|
39
|
+
"X-Proxy-Secret": config.telegram.proxySecret,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const response = await nodeFetch(fileUrl, fetchOptions);
|
|
30
43
|
if (!response.ok) {
|
|
31
44
|
throw new Error(`Failed to download file: ${response.status} ${response.statusText}`);
|
|
32
45
|
}
|
|
@@ -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
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -62,12 +62,33 @@ function getOptionalTtsProviderEnvVar(key, defaultValue) {
|
|
|
62
62
|
}
|
|
63
63
|
return defaultValue;
|
|
64
64
|
}
|
|
65
|
-
export
|
|
66
|
-
|
|
65
|
+
export function buildTelegramConfig() {
|
|
66
|
+
const proxyUrl = getEnvVar("TELEGRAM_PROXY_URL", false);
|
|
67
|
+
// grammY rejects an apiRoot ending with `/`, so normalize once at config
|
|
68
|
+
// load instead of leaking the concern into every consumer.
|
|
69
|
+
const apiRoot = getEnvVar("TELEGRAM_API_ROOT", false).replace(/\/+$/, "");
|
|
70
|
+
const proxySecret = getEnvVar("TELEGRAM_PROXY_SECRET", false);
|
|
71
|
+
const forceIpv4 = getOptionalBooleanEnvVar("TELEGRAM_FORCE_IPV4", false);
|
|
72
|
+
if (proxyUrl && apiRoot) {
|
|
73
|
+
throw new Error("TELEGRAM_PROXY_URL and TELEGRAM_API_ROOT are alternative connectivity modes and cannot be used together. " +
|
|
74
|
+
"TELEGRAM_PROXY_URL tunnels TCP through a SOCKS/HTTP forward proxy; " +
|
|
75
|
+
"TELEGRAM_API_ROOT routes API calls through an HTTPS reverse proxy. Pick one.");
|
|
76
|
+
}
|
|
77
|
+
if (proxySecret && !apiRoot) {
|
|
78
|
+
throw new Error("TELEGRAM_PROXY_SECRET requires TELEGRAM_API_ROOT to be set. " +
|
|
79
|
+
"Without a custom API root, the secret header would be sent to api.telegram.org.");
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
67
82
|
token: getEnvVar("TELEGRAM_BOT_TOKEN"),
|
|
68
83
|
allowedUserId: parseInt(getEnvVar("TELEGRAM_ALLOWED_USER_ID"), 10),
|
|
69
|
-
proxyUrl
|
|
70
|
-
|
|
84
|
+
proxyUrl,
|
|
85
|
+
apiRoot,
|
|
86
|
+
proxySecret,
|
|
87
|
+
forceIpv4,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export const config = {
|
|
91
|
+
telegram: buildTelegramConfig(),
|
|
71
92
|
opencode: {
|
|
72
93
|
apiUrl: getEnvVar("OPENCODE_API_URL", false) || "http://localhost:4096",
|
|
73
94
|
username: getEnvVar("OPENCODE_SERVER_USERNAME", false) || "opencode",
|
|
@@ -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 {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grinev/opencode-telegram-bot",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.4",
|
|
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",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"grammy": "^1.39.2",
|
|
60
60
|
"https-proxy-agent": "^7.0.6",
|
|
61
61
|
"mdast-util-to-string": "^4.0.0",
|
|
62
|
+
"node-fetch": "^2.7.0",
|
|
62
63
|
"remark-gfm": "^4.0.1",
|
|
63
64
|
"remark-parse": "^11.0.0",
|
|
64
65
|
"socks-proxy-agent": "^8.0.5",
|