From 66d69c1245634fdc68ceb6bc46161606c9998d2f Mon Sep 17 00:00:00 2001 From: Nikolay Unuchek Date: Sat, 17 Oct 2026 16:40:15 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B5=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=81=D0=BB=D1=8F=D1=82=D0=BE=D1=80=20Bot=20API=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=BC=D0=B0=D1=88=D0=BD=D0=B5=D0=BC=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B5=20=D0=B8=20TELEGRAM=5FAPI?= =?UTF-8?q?=5FBASE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit С VPS api.telegram.org недоступен. deploy/tg-relay: nginx на адресе Tailscale домашнего сервера, пропускает только запросы нашего бота из tailnet, без логов доступа (токен в URL). api: базовый URL Bot API из TELEGRAM_API_BASE; сетевые сбои polling пишутся в лог раз в 10 минут. --- apps/api/src/config.ts | 3 +++ apps/api/src/index.ts | 2 +- apps/api/src/telegram/bot.ts | 6 ++++++ apps/api/src/telegram/client.ts | 9 ++++++--- deploy/api.env.example | 2 ++ deploy/tg-relay/default.conf.template | 27 +++++++++++++++++++++++++ deploy/tg-relay/deploy.sh | 29 +++++++++++++++++++++++++++ deploy/tg-relay/docker-compose.yml | 23 +++++++++++++++++++++ docs/ai-log.md | 2 ++ 9 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 deploy/tg-relay/default.conf.template create mode 100755 deploy/tg-relay/deploy.sh create mode 100644 deploy/tg-relay/docker-compose.yml diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 4da7140..f8d528a 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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'), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 77c12ed..bb8e35c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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; diff --git a/apps/api/src/telegram/bot.ts b/apps/api/src/telegram/bot.ts index 1bbaa21..4b5f6d2 100644 --- a/apps/api/src/telegram/bot.ts +++ b/apps/api/src/telegram/bot.ts @@ -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 | 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); } diff --git a/apps/api/src/telegram/client.ts b/apps/api/src/telegram/client.ts index 5eb775a..70b93db 100644 --- a/apps/api/src/telegram/client.ts +++ b/apps/api/src/telegram/client.ts @@ -50,14 +50,17 @@ export type SendMessageParams = { export type TelegramClient = ReturnType; -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('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 { - 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'); diff --git a/deploy/api.env.example b/deploy/api.env.example index 899b93a..084f522 100644 --- a/deploy/api.env.example +++ b/deploy/api.env.example @@ -12,3 +12,5 @@ TELEGRAM_LEADS_THREAD_ID= TELEGRAM_SYSTEM_THREAD_ID= # Telegram user ID автора: только ему бот отвечает на команды администрирования TELEGRAM_ADMIN_ID= +# Ретранслятор Bot API (deploy/tg-relay): с VPS api.telegram.org недоступен. Пусто — напрямую. +TELEGRAM_API_BASE= diff --git a/deploy/tg-relay/default.conf.template b/deploy/tg-relay/default.conf.template new file mode 100644 index 0000000..a306883 --- /dev/null +++ b/deploy/tg-relay/default.conf.template @@ -0,0 +1,27 @@ +# Ретранслятор Bot API для api на VPS: оттуда api.telegram.org недоступен. +# Пропускает только запросы нашего бота и только из tailnet. Токен идёт в URL — логов доступа нет. +server { + listen 8788; + access_log off; + error_log /dev/stderr crit; + + location ~ ^/(file/)?bot${TELEGRAM_BOT_ID}: { + allow ${ALLOW_FROM}; + deny all; + + proxy_pass https://api.telegram.org; + proxy_ssl_server_name on; + proxy_set_header Host api.telegram.org; + proxy_http_version 1.1; + # long polling getUpdates держит запрос до 25 с + proxy_read_timeout 70s; + proxy_send_timeout 70s; + proxy_buffering off; + # бэкап базы уходит администратору через sendDocument + client_max_body_size 50m; + } + + location / { + return 404; + } +} diff --git a/deploy/tg-relay/deploy.sh b/deploy/tg-relay/deploy.sh new file mode 100755 index 0000000..8d18ce6 --- /dev/null +++ b/deploy/tg-relay/deploy.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Ставит ретранслятор Bot API на домашний сервер. Запускать локально из корня: +# TELEGRAM_BOT_ID=8959389487 deploy/tg-relay/deploy.sh +# RELAY_HOST (по умолчанию nick@192.168.100.50), RELAY_BIND — адрес Tailscale хоста (по умолчанию его tailscale ip -4). +# Токен бота сюда не нужен: ретранслятор только пересылает запросы. +set -euo pipefail + +RELAY_HOST="${RELAY_HOST:-nick@192.168.100.50}" +RELAY_PATH="${RELAY_PATH:-/home/nick/home_server/kvarta_tg_relay}" +: "${TELEGRAM_BOT_ID:?задайте TELEGRAM_BOT_ID — числовая часть токена до двоеточия}" +[[ $TELEGRAM_BOT_ID =~ ^[0-9]+$ ]] || { echo "TELEGRAM_BOT_ID — только цифры" >&2; exit 1; } + +cd "$(dirname "$0")" +SSH=(ssh -o BatchMode=yes -o ConnectTimeout=10 "$RELAY_HOST") + +RELAY_BIND="${RELAY_BIND:-$("${SSH[@]}" 'tailscale ip -4 2>/dev/null | head -1')}" +[[ -n $RELAY_BIND ]] || { echo "у $RELAY_HOST нет адреса Tailscale: выполните там «sudo tailscale up» или задайте RELAY_BIND" >&2; exit 1; } + +echo "[1] файлы → $RELAY_HOST:$RELAY_PATH" +"${SSH[@]}" "mkdir -p $RELAY_PATH" +scp -q -o BatchMode=yes docker-compose.yml default.conf.template "$RELAY_HOST:$RELAY_PATH/" +"${SSH[@]}" "printf 'TELEGRAM_BOT_ID=%s\nRELAY_BIND=%s\nALLOW_FROM=%s\n' '$TELEGRAM_BOT_ID' '$RELAY_BIND' '${ALLOW_FROM:-100.64.0.0/10}' > $RELAY_PATH/.env" + +echo "[2] запуск на $RELAY_BIND:8788" +"${SSH[@]}" "cd $RELAY_PATH && docker compose up -d --force-recreate" + +echo "[3] проверка: чужой путь → 404" +"${SSH[@]}" "curl -s -o /dev/null -w '%{http_code}\n' http://$RELAY_BIND:8788/" +echo "Готово. В api.env на VPS: TELEGRAM_API_BASE=http://$RELAY_BIND:8788" diff --git a/deploy/tg-relay/docker-compose.yml b/deploy/tg-relay/docker-compose.yml new file mode 100644 index 0000000..0f13388 --- /dev/null +++ b/deploy/tg-relay/docker-compose.yml @@ -0,0 +1,23 @@ +# Домашний сервер (выход в интернет не из РФ): /home/nick/home_server/kvarta_tg_relay. +# Ставит deploy/tg-relay/deploy.sh; .env там же (TELEGRAM_BOT_ID, RELAY_BIND, ALLOW_FROM). +name: kvarta-tg-relay + +services: + relay: + image: nginx:1.27-alpine + container_name: kvarta-tg-relay + restart: unless-stopped + ports: + # только на адресе Tailscale: из LAN и интернета ретранслятор не виден + - "${RELAY_BIND}:8788:8788" + environment: + TELEGRAM_BOT_ID: ${TELEGRAM_BOT_ID} + ALLOW_FROM: ${ALLOW_FROM:-100.64.0.0/10} + NGINX_ENVSUBST_FILTER: ^(TELEGRAM_BOT_ID|ALLOW_FROM)$ + volumes: + - ./default.conf.template:/etc/nginx/templates/default.conf.template:ro + logging: + driver: json-file + options: + max-size: "1m" + max-file: "2" diff --git a/docs/ai-log.md b/docs/ai-log.md index 0d2ebae..0c3aba3 100644 --- a/docs/ai-log.md +++ b/docs/ai-log.md @@ -21,3 +21,5 @@ | 15 | Шаг 9, тесты | Тест «без CHAT_ID» передавал `undefined`, и деструктуризация подставляла значение по умолчанию — тест проверял не то | Сам тест повёл себя не так, как ожидалось | `null` вместо `undefined` | | 16 | План, восстановление | В плане пауза приёма включалась после снимка pre-restore — заявка, пришедшая во время gzip снимка, пропала бы | Агент api при реализации | Пауза до снимка, ожидание опустошения очереди Telegram | | 17 | Шаг 5, прод | `TRUSTED_PROXIES=172.31.120.1` не срабатывал: Node отдаёт адрес сокета как `::ffff:172.31.120.1`, и все посетители снова делили один лимит. Тесты этого не ловили — в них адреса без префикса | Проверка на проде: 6 запросов с X-Real-IP «A», затем «B» получил 429 | Нормализация IPv4-mapped адресов + тест; на сервере проверено пробным контейнером | +| 18 | План, Telegram | План исходил из того, что VPS ходит в api.telegram.org напрямую; не проверил — с сервера в РФ Telegram недоступен (таймаут), бот не получил `/setup` | Тишина после `/setup` автора, затем проверка `fetch` из контейнера | Ретранслятор Bot API на домашнем сервере (`deploy/tg-relay`) по Tailscale + `TELEGRAM_API_BASE` | +| 19 | Шаг 9, бот | Сетевые ошибки `getUpdates` молча повторялись без единой строки в логе — недоступность Telegram была невидима | Пустой `docker logs` при неработающем боте | Предупреждение в лог раз в 10 минут |