feat(telegram): бот выживает при мёртвом прокси и сам сообщает о проблемах

Корень «бот молчит»: get_proxy_args считал достаточным условием маршрутизации
через 4534 сам факт запущенного sing-box - если mixed-inbound не поднялся,
каждый запрос вечно падал, воркер уходил в бесконечный backoff (468 ретраев
подряд у пользователя), хотя api.telegram.org был доступен напрямую.

- mixed_port_alive(): TCP-проба слушается ли 4534 (netstat) с кэшем 30с;
  маршрут через прокси выдаётся только при живом порте
- tg_request разделён на tg_request_via + fallback-цепочку: прокси не ответил -
  повтор без прокси; отключается UCI telegram.bot_direct_fallback='0'
- алерт админу однократно за эпизод после 5 неудачных опросов подряд с
  указанием причины; сбрасывается при восстановлении опроса
- heartbeat /var/run/tachyon_telegram.heartbeat после каждого успешного
  цикла - основа для вотчдог-проверки живости (следующий коммит)
- ротация tmpfs-лога воркера: потолок 512KB, хвост 100KB, проверка раз в час
- /doctor в боте рендерит человекочитаемую сводку (проблемы/исправлено/
  запланировано) вместо сырого JSON
- config_version убран из человекочитаемого вывода конфига (внутренний
  маркер миграций)
This commit is contained in:
Dushnilin
2026-08-22 10:06:56 +03:00
parent c910e6368e
commit 28c5c19645
2 changed files with 222 additions and 6 deletions
+169 -6
View File
@@ -103,9 +103,52 @@ function settings() {
return object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
}
// Liveness cache for the mixed proxy port: probing netstat on every poll
// tick would spawn a process every few seconds for a fact that rarely
// changes. A dead port is re-probed on this TTL; a live one too.
const MIXED_PORT_PROBE_TTL = 30;
let mixed_port_alive_cached = null;
let mixed_port_checked_at = 0;
function mixed_port_alive() {
let now = time();
if (mixed_port_alive_cached != null && (now - mixed_port_checked_at) < MIXED_PORT_PROBE_TTL)
return mixed_port_alive_cached;
let alive = false;
let out = command_output_from_args([ "netstat", "-ltn" ]);
for (let line in split(out, "\n")) {
if (index(line, ":4534 ") >= 0) {
alive = true;
break;
}
}
mixed_port_alive_cached = alive;
mixed_port_checked_at = now;
return alive;
}
// Direct-to-Telegram fallback: when the local proxy route is broken the bot
// used to go permanently silent even though api.telegram.org was reachable
// straight from the router. Enabled by default, opt-out via UCI
// telegram.bot_direct_fallback='0'.
function direct_fallback_enabled() {
return trim(as_string(settings().bot_direct_fallback || "1")) != "0";
}
function get_proxy_args() {
let cfg = settings();
if (command_success_from_args(["pidof", "sing-box"])) {
// A running sing-box alone proves nothing: the mixed inbound may have
// failed to bind while the rest of the core came up. Only route bot
// traffic through 4534 when something actually listens there.
if (!mixed_port_alive()) {
if (direct_fallback_enabled())
return [];
// Paranoid mode (fallback disabled): keep legacy behavior.
return [ "--proxy", "http://127.0.0.1:4534" ];
}
// If a specific section is configured for the bot, force-select it
// through the Mihomo REST API so the mixed proxy routes bot traffic
// through the right outbound.
@@ -129,7 +172,58 @@ function get_proxy_args() {
// ─── Telegram API Core ───────────────────────────────────────────────────────
function tg_request(token, method, payload) {
const HEARTBEAT_FILE = "/var/run/tachyon_telegram.heartbeat";
const LOG_FILE = "/var/log/tachyon_telegram.log";
// The worker log lives on tmpfs: without a ceiling a chatty error loop
// slowly eats router RAM. Rotate when the file exceeds this size.
const LOG_MAX_BYTES = 524288;
const LOG_KEEP_BYTES = 102400;
// Liveness stamp for the watchdog: a worker that stopped touching this file
// while still owning the pid is stuck (hung long-poll, deadlocked loop) and
// gets restarted by the watchdog's slow checks.
function write_heartbeat() {
write_text_file(HEARTBEAT_FILE, as_string(time()) + "\n");
}
function rotate_log_if_needed() {
let st = fs.stat(LOG_FILE);
if (!st || int(st.size || 0) <= LOG_MAX_BYTES)
return;
let data = fs.readfile(LOG_FILE);
if (data == null)
return;
let tail = substr(data, length(data) - LOG_KEEP_BYTES);
// Keep the most recent lines only; leading partial line is harmless.
let newline = index(tail, "\n");
if (newline >= 0)
tail = substr(tail, newline + 1);
fs.writefile(LOG_FILE, "--- log truncated (size cap) ---\n" + tail);
}
function alert_route_failure(attempt, proxy_alive) {
// One-shot per failure episode - the caller clears the flag as soon as
// polling recovers. Delivery rides on tg_request's own fallback chain:
// if the proxy route is what's broken, the message goes out directly.
let cfg = settings();
let token = trim(as_string(cfg.bot_token || ""));
if (token == "") return;
let admins = split(trim(as_string(cfg.admin_ids || "")), /,/);
let chat_id = trim(as_string(admins[0] || ""));
if (chat_id == "") return;
let hint = proxy_alive
? "прокси-порт 4534 слушается, но запросы через него не проходят"
: "прокси-порт 4534 не слушается (mixed-inbound sing-box не поднялся)";
let text = "⚠️ <b>Tachyon Telegram бот:</b> " + as_string(attempt) +
" неудачных попыток связи с API подряд.\n" +
"Причина: " + hint + ".\n" +
"Включён прямой доступ к API как запасной путь — команды могут отвечать с задержкой.\n" +
"Watchdog попробует восстановить прокси автоматически.";
send_message(token, chat_id, text, "HTML", null);
}
function tg_request_via(token, method, payload, proxy_args) {
if (!token) return null;
let url = "https://api.telegram.org/bot" + token + "/" + method;
// Pass the JSON body directly to curl's -d argument so there is no temp
@@ -140,14 +234,31 @@ function tg_request(token, method, payload) {
let args = [ "curl", "-s", "-m", "35", "--connect-timeout", "10",
"-X", "POST", "-H", "Content-Type: application/json",
"-d", body ];
let proxy = get_proxy_args();
for (let p in proxy) push(args, p);
for (let p in proxy_args) push(args, p);
push(args, url);
let res = command_capture(command_from_args(args));
if (!res || res.status != 0 || res.output == "") return null;
try { return json(res.output); } catch (e) { return null; }
}
function tg_request(token, method, payload) {
if (!token) return null;
let proxy_args = get_proxy_args();
let res = tg_request_via(token, method, payload, proxy_args);
if (res != null)
return res;
// Proxy route produced nothing (dead mixed inbound, refused connection,
// timeout). When the direct fallback is allowed, retry without proxy
// before giving up - api.telegram.org is usually reachable straight from
// the router even while the proxy chain is being repaired.
if (direct_fallback_enabled() && length(proxy_args) > 0)
res = tg_request_via(token, method, payload, []);
return res;
}
function send_message(token, chat_id, text, parse_mode, keyboard) {
text = as_string(text);
if (length(text) > 3900) text = substr(text, 0, 3900) + "\n... (сообщение сокращено)";
@@ -384,7 +495,7 @@ function format_bytes(b) {
let setting_schema = {
settings: {
config_version: "Версия конфига",
// config_version is an internal migration marker - not shown.
dns_type: "Тип DNS",
dns_server: "DNS Серверы",
bootstrap_dns_server: "Bootstrap DNS",
@@ -1049,9 +1160,43 @@ function handle_sec_clear(token, chat_id, msg_id, sec_name, list_type) {
}
function exec_doctor(token, chat_id) {
send_message(token, chat_id, "⏳ <b>Запуск диагностики и авто-исправления...</b>", "HTML");
send_message(token, chat_id, "⏳ <b>Запуск диагностики...</b>", "HTML");
let res = command_capture(command_from_args([ "/usr/bin/tachyon", "doctor" ]));
let report = res ? (res.output || "Нет вывода диагностики.") : "Ошибка запуска диагностики.";
// The CLI prints a JSON envelope; render a readable summary instead of
// dumping raw JSON into the chat.
let data = null;
try { data = json(report); } catch (e) {}
if (data != null && type(data) == "object") {
let text = trim(as_string(data.report || ""));
if (text == "") {
report = "Пустой отчёт диагностики.";
} else {
let header;
if (data.busy) {
header = "Диагностика уже выполняется другим процессом.";
} else if (int(data.issues || 0) > 0) {
header = sprintf("Проблем: %s", as_string(data.issues));
if (int(data.fixed || 0) > 0)
header += sprintf(", исправлено: %s", as_string(data.fixed));
let planned = length(data.planned_fixes || []);
if (planned > 0)
header += sprintf(", планируется к исправлению: %d", planned) +
"\n(применение: tachyon doctor --fix или кнопка в LuCI)";
} else {
header = "Проблем не обнаружено.";
}
report = "<b>" + escape_html(header) + "</b>\n\n<pre>" + escape_html(text) + "</pre>";
// send_message caps at ~3900 chars; keep the tail (summary lines)
// visible rather than cutting mid-report without notice.
if (length(report) > 3900)
report = substr(report, 0, 3900) + "\n... (отчёт сокращён)";
send_message(token, chat_id, report, "HTML", [[{text:"⬅️ Назад", callback_data:"/menu"}]]);
return;
}
}
if (length(report) > 3500) report = substr(report, 0, 3500) + "\n... (отчёт сокращён)";
send_message(token, chat_id, "🩺 <b>Результаты Tachyon Doctor:</b>\n\n<pre>" + escape_html(report) + "</pre>", "HTML", [[{text:"⬅️ Назад", callback_data:"/menu"}]]);
}
@@ -2874,23 +3019,36 @@ function worker() {
let last_update_check = 0;
let last_blocked_check = 0;
let consecutive_failures = 0;
let route_alert_sent = false;
let last_log_check = 0;
while (true) {
try {
cfg = settings();
if (cfg.enabled != "1") break;
let res = process_updates(cfg.bot_token, cfg.admin_ids);
if (res === false) {
consecutive_failures++;
let backoff = poll_interval * (1 << min(consecutive_failures - 1, 4));
if (backoff > 300) backoff = 300;
let log_level = consecutive_failures >= 3 ? "[warn]" : "[info]";
command_success_from_args(["logger", "-t", "tachyon-telegram", log_level + " API poll retry " + as_string(consecutive_failures) + ", backing off " + as_string(backoff) + "s"]);
// Tell the admin once per failure episode that the usual
// proxy route is down and the direct fallback carries the
// bot meanwhile.
if (!route_alert_sent && consecutive_failures >= 5) {
route_alert_sent = true;
alert_route_failure(consecutive_failures, mixed_port_alive());
}
sleep(backoff * 1000);
continue;
}
consecutive_failures = 0;
route_alert_sent = false;
write_heartbeat();
let now = time();
// localtime() yields { sec, min, hour, mday, mon, year, ... }.
@@ -2912,6 +3070,11 @@ function worker() {
check_blocked_activity(cfg);
last_blocked_check = now;
}
if (now - last_log_check > 3600) {
rotate_log_if_needed();
last_log_check = now;
}
} catch (e) {
consecutive_failures++;
command_success_from_args(["logger", "-t", "tachyon-telegram", "[err] Worker loop error: " + as_string(e)]);