feat: ретранслятор Bot API на домашнем сервере и TELEGRAM_API_BASE

С VPS api.telegram.org недоступен. deploy/tg-relay: nginx на адресе
Tailscale домашнего сервера, пропускает только запросы нашего бота из
tailnet, без логов доступа (токен в URL). api: базовый URL Bot API из
TELEGRAM_API_BASE; сетевые сбои polling пишутся в лог раз в 10 минут.
This commit is contained in:
2026-10-17 16:56:52 +03:00
parent 55ecf7c9d8
commit 66d69c1245
9 changed files with 99 additions and 4 deletions
+3
View File
@@ -3,6 +3,8 @@
import { DEFAULT_DB_PATH } from './db/index.js';
export type TelegramConfig = {
/** Базовый URL Bot API: ретранслятор, если api.telegram.org напрямую недоступен. */
apiBase?: string;
token?: string;
/** id группы, у супергрупп отрицательный: -100… */
chatId?: number;
@@ -50,6 +52,7 @@ export function loadConfig(env: Env = process.env): Config {
.filter(Boolean),
telegram: {
token,
apiBase: optional(env, 'TELEGRAM_API_BASE')?.replace(/\/+$/, ''),
chatId: optionalInt(env, 'TELEGRAM_CHAT_ID'),
leadsThreadId: optionalInt(env, 'TELEGRAM_LEADS_THREAD_ID'),
systemThreadId: optionalInt(env, 'TELEGRAM_SYSTEM_THREAD_ID'),
+1 -1
View File
@@ -14,7 +14,7 @@ import { createSystemReporter, shortError } from './telegram/system.js';
const config = loadConfig();
const tg = config.telegram;
const now = () => new Date();
const client = tg.token ? createTelegramClient({ token: tg.token }) : undefined;
const client = tg.token ? createTelegramClient({ token: tg.token, apiBase: tg.apiBase }) : undefined;
const system = createSystemReporter({ client, chatId: tg.chatId, threadId: tg.systemThreadId, now });
let holder: DbHolder;
+6
View File
@@ -8,6 +8,7 @@ import { shortError, type SystemReporter } from './system.js';
export const LEADS_TOPIC = 'Kvarta Cafe: Заявки';
export const SYSTEM_TOPIC = 'Kvarta Cafe: Система';
const POLL_TIMEOUT_SEC = 25;
const NETWORK_WARN_EVERY_MS = 10 * 60 * 1000;
const RETRY_PAUSE_MS = 5000;
export type Bot = {
@@ -45,6 +46,7 @@ export function createBot({
}): Bot {
let me: TgUser | undefined;
let offset: number | undefined;
let lastNetworkWarnAt = 0;
let running = false;
let abort: AbortController | undefined;
let loop: Promise<void> | undefined;
@@ -156,6 +158,10 @@ export function createBot({
if (!running) return;
if (err instanceof TelegramError && err.kind === 'config') {
await system.report(`polling ${err.code}`, `Бот не получает обновления: ${err.message}`);
} else if (Date.now() - lastNetworkWarnAt >= NETWORK_WARN_EVERY_MS) {
// Сеть до Telegram недоступна — в «Систему» не сообщить, остаётся лог контейнера.
lastNetworkWarnAt = Date.now();
console.warn(`[bot] getUpdates не удался: ${err instanceof Error ? err.message : String(err)}`);
}
await pause(err instanceof TelegramError && err.retryAfterSec ? err.retryAfterSec * 1000 : retryPauseMs);
}
+6 -3
View File
@@ -50,14 +50,17 @@ export type SendMessageParams = {
export type TelegramClient = ReturnType<typeof createTelegramClient>;
const API = 'https://api.telegram.org';
// С VPS в РФ api.telegram.org недоступен — ходим через ретранслятор (TELEGRAM_API_BASE, см. deploy/tg-relay).
export const DEFAULT_API_BASE = 'https://api.telegram.org';
export function createTelegramClient({
token,
apiBase = DEFAULT_API_BASE,
fetch = globalThis.fetch,
timeoutMs = 5000,
}: {
token: string;
apiBase?: string;
fetch?: FetchLike;
timeoutMs?: number;
}) {
@@ -81,7 +84,7 @@ export function createTelegramClient({
params instanceof FormData
? { method: 'POST', body: params }
: { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(params) };
const res = await request(method, `${API}/bot${token}/${method}`, init, opts.timeoutMs ?? timeoutMs, opts.signal);
const res = await request(method, `${apiBase}/bot${token}/${method}`, init, opts.timeoutMs ?? timeoutMs, opts.signal);
let body: { ok?: boolean; result?: T; description?: string; parameters?: { retry_after?: number } };
try {
@@ -133,7 +136,7 @@ export function createTelegramClient({
call<TgMessage | boolean>('editMessageText', params),
getFile: (file_id: string) => call<{ file_id: string; file_path?: string; file_size?: number }>('getFile', { file_id }),
async downloadFile(filePath: string, maxBytes: number): Promise<Uint8Array> {
const res = await request('downloadFile', `${API}/file/bot${token}/${filePath}`, {}, 60_000);
const res = await request('downloadFile', `${apiBase}/file/bot${token}/${filePath}`, {}, 60_000);
if (!res.ok) throw new TelegramError(`downloadFile: ${res.status}`, res.status >= 500 ? 'retryable' : 'config', res.status);
const data = new Uint8Array(await res.arrayBuffer());
if (data.byteLength > maxBytes) throw new TelegramError('downloadFile: файл больше лимита', 'config');