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:
@@ -3,6 +3,8 @@
|
|||||||
import { DEFAULT_DB_PATH } from './db/index.js';
|
import { DEFAULT_DB_PATH } from './db/index.js';
|
||||||
|
|
||||||
export type TelegramConfig = {
|
export type TelegramConfig = {
|
||||||
|
/** Базовый URL Bot API: ретранслятор, если api.telegram.org напрямую недоступен. */
|
||||||
|
apiBase?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
/** id группы, у супергрупп отрицательный: -100… */
|
/** id группы, у супергрупп отрицательный: -100… */
|
||||||
chatId?: number;
|
chatId?: number;
|
||||||
@@ -50,6 +52,7 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
telegram: {
|
telegram: {
|
||||||
token,
|
token,
|
||||||
|
apiBase: optional(env, 'TELEGRAM_API_BASE')?.replace(/\/+$/, ''),
|
||||||
chatId: optionalInt(env, 'TELEGRAM_CHAT_ID'),
|
chatId: optionalInt(env, 'TELEGRAM_CHAT_ID'),
|
||||||
leadsThreadId: optionalInt(env, 'TELEGRAM_LEADS_THREAD_ID'),
|
leadsThreadId: optionalInt(env, 'TELEGRAM_LEADS_THREAD_ID'),
|
||||||
systemThreadId: optionalInt(env, 'TELEGRAM_SYSTEM_THREAD_ID'),
|
systemThreadId: optionalInt(env, 'TELEGRAM_SYSTEM_THREAD_ID'),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { createSystemReporter, shortError } from './telegram/system.js';
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const tg = config.telegram;
|
const tg = config.telegram;
|
||||||
const now = () => new Date();
|
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 });
|
const system = createSystemReporter({ client, chatId: tg.chatId, threadId: tg.systemThreadId, now });
|
||||||
|
|
||||||
let holder: DbHolder;
|
let holder: DbHolder;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { shortError, type SystemReporter } from './system.js';
|
|||||||
export const LEADS_TOPIC = 'Kvarta Cafe: Заявки';
|
export const LEADS_TOPIC = 'Kvarta Cafe: Заявки';
|
||||||
export const SYSTEM_TOPIC = 'Kvarta Cafe: Система';
|
export const SYSTEM_TOPIC = 'Kvarta Cafe: Система';
|
||||||
const POLL_TIMEOUT_SEC = 25;
|
const POLL_TIMEOUT_SEC = 25;
|
||||||
|
const NETWORK_WARN_EVERY_MS = 10 * 60 * 1000;
|
||||||
const RETRY_PAUSE_MS = 5000;
|
const RETRY_PAUSE_MS = 5000;
|
||||||
|
|
||||||
export type Bot = {
|
export type Bot = {
|
||||||
@@ -45,6 +46,7 @@ export function createBot({
|
|||||||
}): Bot {
|
}): Bot {
|
||||||
let me: TgUser | undefined;
|
let me: TgUser | undefined;
|
||||||
let offset: number | undefined;
|
let offset: number | undefined;
|
||||||
|
let lastNetworkWarnAt = 0;
|
||||||
let running = false;
|
let running = false;
|
||||||
let abort: AbortController | undefined;
|
let abort: AbortController | undefined;
|
||||||
let loop: Promise<void> | undefined;
|
let loop: Promise<void> | undefined;
|
||||||
@@ -156,6 +158,10 @@ export function createBot({
|
|||||||
if (!running) return;
|
if (!running) return;
|
||||||
if (err instanceof TelegramError && err.kind === 'config') {
|
if (err instanceof TelegramError && err.kind === 'config') {
|
||||||
await system.report(`polling ${err.code}`, `Бот не получает обновления: ${err.message}`);
|
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);
|
await pause(err instanceof TelegramError && err.retryAfterSec ? err.retryAfterSec * 1000 : retryPauseMs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,14 +50,17 @@ export type SendMessageParams = {
|
|||||||
|
|
||||||
export type TelegramClient = ReturnType<typeof createTelegramClient>;
|
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({
|
export function createTelegramClient({
|
||||||
token,
|
token,
|
||||||
|
apiBase = DEFAULT_API_BASE,
|
||||||
fetch = globalThis.fetch,
|
fetch = globalThis.fetch,
|
||||||
timeoutMs = 5000,
|
timeoutMs = 5000,
|
||||||
}: {
|
}: {
|
||||||
token: string;
|
token: string;
|
||||||
|
apiBase?: string;
|
||||||
fetch?: FetchLike;
|
fetch?: FetchLike;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -81,7 +84,7 @@ export function createTelegramClient({
|
|||||||
params instanceof FormData
|
params instanceof FormData
|
||||||
? { method: 'POST', body: params }
|
? { method: 'POST', body: params }
|
||||||
: { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(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 } };
|
let body: { ok?: boolean; result?: T; description?: string; parameters?: { retry_after?: number } };
|
||||||
try {
|
try {
|
||||||
@@ -133,7 +136,7 @@ export function createTelegramClient({
|
|||||||
call<TgMessage | boolean>('editMessageText', params),
|
call<TgMessage | boolean>('editMessageText', params),
|
||||||
getFile: (file_id: string) => call<{ file_id: string; file_path?: string; file_size?: number }>('getFile', { file_id }),
|
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> {
|
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);
|
if (!res.ok) throw new TelegramError(`downloadFile: ${res.status}`, res.status >= 500 ? 'retryable' : 'config', res.status);
|
||||||
const data = new Uint8Array(await res.arrayBuffer());
|
const data = new Uint8Array(await res.arrayBuffer());
|
||||||
if (data.byteLength > maxBytes) throw new TelegramError('downloadFile: файл больше лимита', 'config');
|
if (data.byteLength > maxBytes) throw new TelegramError('downloadFile: файл больше лимита', 'config');
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ TELEGRAM_LEADS_THREAD_ID=
|
|||||||
TELEGRAM_SYSTEM_THREAD_ID=
|
TELEGRAM_SYSTEM_THREAD_ID=
|
||||||
# Telegram user ID автора: только ему бот отвечает на команды администрирования
|
# Telegram user ID автора: только ему бот отвечает на команды администрирования
|
||||||
TELEGRAM_ADMIN_ID=
|
TELEGRAM_ADMIN_ID=
|
||||||
|
# Ретранслятор Bot API (deploy/tg-relay): с VPS api.telegram.org недоступен. Пусто — напрямую.
|
||||||
|
TELEGRAM_API_BASE=
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+29
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -21,3 +21,5 @@
|
|||||||
| 15 | Шаг 9, тесты | Тест «без CHAT_ID» передавал `undefined`, и деструктуризация подставляла значение по умолчанию — тест проверял не то | Сам тест повёл себя не так, как ожидалось | `null` вместо `undefined` |
|
| 15 | Шаг 9, тесты | Тест «без CHAT_ID» передавал `undefined`, и деструктуризация подставляла значение по умолчанию — тест проверял не то | Сам тест повёл себя не так, как ожидалось | `null` вместо `undefined` |
|
||||||
| 16 | План, восстановление | В плане пауза приёма включалась после снимка pre-restore — заявка, пришедшая во время gzip снимка, пропала бы | Агент api при реализации | Пауза до снимка, ожидание опустошения очереди Telegram |
|
| 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 адресов + тест; на сервере проверено пробным контейнером |
|
| 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 минут |
|
||||||
|
|||||||
Reference in New Issue
Block a user