From 8945ea5d0408c84b06d9b0204ce86c36166228a7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 13:12:10 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(ops):=20=D0=BE=D0=BF=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=83=D1=85=D0=BE=D0=B4=D1=8F?= =?UTF-8?q?=D1=82=20=D0=B2=20=D1=82=D0=B5=D0=BC=D1=83=20=C2=AB=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D1=80=D1=82=D1=8B=C2=BB,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B3=D0=BE=D0=B2=D0=BE=D1=80?= =?UTF-8?q?=D0=BA=D1=83=20(#2203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Канал включили — и алерты бэкапов посыпались в ОБЩУЮ тему форума. В форуме Telegram адрес сообщения это пара «чат + тема»: без message_thread_id всё попадает в General, причём без единой ошибки. sendMessage возвращает 200, доставка «успешна», просто не туда. Отказ того же класса, что и всё остальное сегодня: зелено везде, а человек, которому адресован алерт, его не видит. Оба отправителя (ops/lib-backup.sh и ops/uptime-healthcheck.sh) получили условную подстановку ${TELEGRAM_TOPIC_ID:+-d "message_thread_id=..."}. Условная намеренно: пустой message_thread_id= Telegram отвергает вместе со всем сообщением, а молчащий алерт хуже алерта не в той теме. Нет переменной — нет параметра, поведение прежнее бит в бит. Тест ИСПОЛНЯЕТ настоящий notify(), извлечённый из файла построчно, подсовывая подставной curl и проверяя, что реально ушло бы в сеть. Проверять подстроку в файле бессмысленно: она может стоять в мёртвой ветке. Копировать функцию в тест — тоже: копия разойдётся с оригиналом на первой правке. Сорсить файл целиком нельзя: у uptime-healthcheck.sh нет guard'а по BASH_SOURCE, и сорсинг запустил бы настоящие сетевые проверки. --- backend/tests/ops/test_2203_notify_topic.py | 155 ++++++++++++++++++++ ops/lib-backup.sh | 1 + ops/uptime-healthcheck.sh | 1 + 3 files changed, 157 insertions(+) create mode 100644 backend/tests/ops/test_2203_notify_topic.py diff --git a/backend/tests/ops/test_2203_notify_topic.py b/backend/tests/ops/test_2203_notify_topic.py new file mode 100644 index 00000000..df170aac --- /dev/null +++ b/backend/tests/ops/test_2203_notify_topic.py @@ -0,0 +1,155 @@ +"""Оповещения уходят в тему «алерты», а не в общую (#2203/#3078). + +Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты +посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в +форуме Telegram адрес сообщения — пара «чат + тема». Без `message_thread_id` +сообщение попадает в General, и ошибки при этом нет: `sendMessage` возвращает +200, доставка «успешна», просто не туда. + +Отказ ровно того класса, ради которого весь стек и заводится: всё зелёное, а +человек, который должен прочитать алерт, его не видит. + +ПОЧЕМУ ТЕСТ ИСПОЛНЯЕТ, А НЕ ЧИТАЕТ. Проверять наличие подстроки +`message_thread_id` в файле бессмысленно: она может стоять в мёртвой ветке, быть +закомментированной или потеряться при подстановке. Тест подсовывает НАСТОЯЩЕЙ +функции подставной `curl`, который записывает свои аргументы, и смотрит, что +реально ушло бы в сеть. + +Функция извлекается из файла построчно, а не копируется в тест: копия разошлась +бы с оригиналом на первой же правке. Сорсить файл целиком нельзя — у +`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы +настоящие сетевые проверки. +""" + +from __future__ import annotations + +import functools +import os +import re +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SENDERS = { + "lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh", + "uptime-healthcheck.sh": REPO_ROOT / "ops" / "uptime-healthcheck.sh", +} + +FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n' + + +@functools.lru_cache(maxsize=1) +def _drive_root() -> str: + """Куда ЭТОТ bash монтирует диски Windows: `/mnt/c` (WSL) или `/c` (git-bash).""" + r = subprocess.run( + ["bash", "-c", "[ -d /mnt/c ] && echo /mnt || echo ''"], + capture_output=True, + text=True, + timeout=30, + ) + return r.stdout.strip() + + +def _posix(path: Path) -> str: + """`C:/Users/x` → `/mnt/c/Users/x` или `/c/Users/x`, смотря какой bash. + + Путь с буквой диска bash не понимает вовсе, и подставной curl тогда просто не + находится: тест падает с «curl не был вызван», хотя код исправен. Префикс + зависит от оболочки — WSL и git-bash монтируют диски по-разному, — поэтому + определяется запуском, а не угадывается. На Linux (CI) пути уже POSIX и + функция ничего не меняет. + """ + text = path.as_posix() + m = re.match(r"^([A-Za-z]):/(.*)$", text) + return f"{_drive_root()}/{m.group(1).lower()}/{m.group(2)}" if m else text + + +def _extract_notify(script: Path) -> str: + """Вырезать тело notify() из настоящего файла — от заголовка до `}` в нулевой колонке.""" + lines = script.read_text(encoding="utf-8").splitlines() + start = next((i for i, l in enumerate(lines) if l.startswith("notify()")), None) + assert start is not None, f"{script.name}: не нашёл notify() — отправитель переименован" + end = next((i for i in range(start + 1, len(lines)) if lines[i] == "}"), None) + assert end is not None, f"{script.name}: не нашёл конец notify()" + return "\n".join(lines[start : end + 1]) + + +def _run_notify(tmp_path: Path, script: Path, *, topic: str | None) -> list[str]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "curl" + fake.write_text(FAKE_CURL, encoding="utf-8", newline="\n") + fake.chmod(0o755) + + # Заглушки для того, что notify() зовёт помимо curl. + harness = tmp_path / "harness.sh" + harness.write_text( + "log() { :; }\n" + "notify_fallback_mail() { :; }\n" + + _extract_notify(script) + + '\nnotify "тестовое сообщение"\n', + encoding="utf-8", + newline="\n", + ) + + dump = tmp_path / "args.txt" + # Переменные передаются ПРЕФИКСОМ КОМАНДЫ: под Windows git-bash окружение из + # `env=` у subprocess до скрипта не доходит, и тест был бы зелёным по + # неверной причине — просто потому, что curl не вызывался вовсе. + # Значения В КАВЫЧКАХ: в PATH встречается `Program Files (x86)`, и + # незакавыченное присваивание роняет разбор всей команды на скобке. + prefix = [ + f'PATH="{_posix(bin_dir)}:$PATH"', + f'ARGS_DUMP="{_posix(dump)}"', + 'TELEGRAM_BOT_TOKEN="123:FAKE"', + 'TELEGRAM_CHAT_ID="-1004443088679"', + f"BACKUP_ENV_FILE=\"{_posix(tmp_path / 'missing.env')}\"", + ] + if topic is not None: + prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"') + + subprocess.run( + ["bash", "-c", " ".join(prefix) + f' bash "{_posix(harness)}"'], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=30, + env=dict(os.environ), + ) + return dump.read_text(encoding="utf-8").splitlines() if dump.exists() else [] + + +@pytest.mark.parametrize("name", sorted(SENDERS)) +def test_tema_peredayotsya_kogda_zadana(tmp_path: Path, name: str) -> None: + """При заданном TELEGRAM_TOPIC_ID сообщение адресуется в тему.""" + args = _run_notify(tmp_path, SENDERS[name], topic="158") + assert args, f"{name}: curl не был вызван — отправка не дошла до сети" + assert "message_thread_id=158" in args, ( + f"{name}: тема не передана, сообщение уйдёт в общую тему форума:\n{args}" + ) + + +@pytest.mark.parametrize("name", sorted(SENDERS)) +def test_bez_temy_povedenie_prezhnee(tmp_path: Path, name: str) -> None: + """Без переменной параметр не добавляется вовсе. + + Обратный конец инварианта: пустой `message_thread_id=` Telegram отвергает + вместе со всем сообщением. Молчащий алерт хуже алерта не в той теме, поэтому + отсутствие переменной обязано означать отсутствие параметра, а не параметр с + пустым значением. + """ + args = _run_notify(tmp_path, SENDERS[name], topic=None) + assert args, f"{name}: curl не был вызван" + assert not any("message_thread_id" in a for a in args), ( + f"{name}: параметр темы просочился без переменной:\n{args}" + ) + + +@pytest.mark.parametrize("name", sorted(SENDERS)) +def test_adres_i_tekst_na_meste(tmp_path: Path, name: str) -> None: + """Тема добавляется, а не вытесняет адресата и текст.""" + args = _run_notify(tmp_path, SENDERS[name], topic="158") + assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id" + assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст" diff --git a/ops/lib-backup.sh b/ops/lib-backup.sh index c95b4165..417bfe3f 100755 --- a/ops/lib-backup.sh +++ b/ops/lib-backup.sh @@ -60,6 +60,7 @@ notify() { if curl -fsS --max-time 15 \ -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d "chat_id=${TELEGRAM_CHAT_ID}" \ + ${TELEGRAM_TOPIC_ID:+-d "message_thread_id=${TELEGRAM_TOPIC_ID}"} \ -d "disable_web_page_preview=true" \ --data-urlencode "text=${text}" \ >/dev/null 2>&1; then diff --git a/ops/uptime-healthcheck.sh b/ops/uptime-healthcheck.sh index 783b3fd5..9350493c 100755 --- a/ops/uptime-healthcheck.sh +++ b/ops/uptime-healthcheck.sh @@ -84,6 +84,7 @@ notify() { if curl -fsS --max-time "$CURL_TIMEOUT" \ -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d "chat_id=${TELEGRAM_CHAT_ID}" \ + ${TELEGRAM_TOPIC_ID:+-d "message_thread_id=${TELEGRAM_TOPIC_ID}"} \ -d "disable_web_page_preview=true" \ --data-urlencode "text=${text}" \ >/dev/null 2>&1; then -- 2.45.3 From 6446a8cdf5457fb4ead1987ff581c62432488216 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 13:16:26 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(tests):=20ruff=20E741=20=E2=80=94=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D0=B1=D1=83=D0=BA=D0=B2=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=B8=D0=BC=D1=8F=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D0=B5=20=D1=81=D0=BA=D1=80=D0=B8?= =?UTF-8?q?=D0=BF=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/ops/test_2203_notify_topic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/ops/test_2203_notify_topic.py b/backend/tests/ops/test_2203_notify_topic.py index df170aac..cad0f108 100644 --- a/backend/tests/ops/test_2203_notify_topic.py +++ b/backend/tests/ops/test_2203_notify_topic.py @@ -69,7 +69,7 @@ def _posix(path: Path) -> str: def _extract_notify(script: Path) -> str: """Вырезать тело notify() из настоящего файла — от заголовка до `}` в нулевой колонке.""" lines = script.read_text(encoding="utf-8").splitlines() - start = next((i for i, l in enumerate(lines) if l.startswith("notify()")), None) + start = next((i for i, ln in enumerate(lines) if ln.startswith("notify()")), None) assert start is not None, f"{script.name}: не нашёл notify() — отправитель переименован" end = next((i for i in range(start + 1, len(lines)) if lines[i] == "}"), None) assert end is not None, f"{script.name}: не нашёл конец notify()" -- 2.45.3