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');
+2
View File
@@ -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=
+27
View File
@@ -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;
}
}
+29
View File
@@ -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"
+23
View File
@@ -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"
+2
View File
@@ -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 минут |