diff --git a/CHANGELOG.md b/CHANGELOG.md index c3fd024..cc58e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Подавление Telegram link-preview в карточке трекера / уведомлениях** (ORCH-080): под каждой карточкой трекера (`bump` и `edit`) и под notify/alert-сообщениями Telegram разворачивал баннер «Plane — Modern project management» для кликабельной ссылки `ORCH-NNN` на issue. В дефолтном `bump`-режиме (ORCH-067) карточка пересоздаётся на каждом переходе → баннер дублировался и засорял ленту (жалоба Owner, 08.06). **Корень:** JSON-payload обоих низкоуровневых примитивов `notifications.send_telegram` (`POST /sendMessage`) и `notifications.edit_telegram` (`POST /editMessageText`) не содержал ключ `disable_web_page_preview`. **Фикс (ADR-001, минимальная аддитивная правка на уровне примитива):** добавлен `"disable_web_page_preview": True` в payload обоих методов — гасит баннер у ВСЕХ потребителей (`update_task_tracker` в обоих режимах, `notify_approve_requested`, `notify_error`, alert'ы стадий из `launcher`/`stage_engine`) без изменения их кода. Безусловно, без kill-switch (превью трекера не нужно никому, риск нулевой). `parse_mode: "HTML"` сохранён в обоих payload → ссылка `ORCH-NNN` остаётся кликабельной; `disable_notification` (карточка тихая), bump/edit-логика, инвариант «одна карточка на задачу», контракты возврата (`send_telegram → message_id|None`, `edit_telegram → EDIT_*`) и never-raise — не затронуты. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД — без изменений. ADR `docs/work-items/ORCH-080/06-adr/ADR-001-disable-telegram-link-preview.md`. Тесты: `tests/test_link_preview_disabled.py` (TC-01..06: флаг в обоих payload, регрессия `parse_mode`/полей, контракты возврата, never-raise). Документация: `CLAUDE.md` + `docs/architecture/README.md` (компонент Notifications). - **Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD «no open PR»)** (ORCH-082/ORCH-81): закрыт отсутствующий инвариант «к моменту merge-verify у ветки есть открытый код-PR». **Корень (ORCH-074, 08.06):** PR создавался единственной `launcher._ensure_pr` ТОЛЬКО на developer-пути и ТОЛЬКО при свежем worktree-коммите (`exit==0 → git status непуст → commit → push → agent=="developer"`); после ручных восстановлений `main` у ветки ORCH-074 не оказалось открытого код-PR → детерминированный `merge_gate.merge_pr` вернул `("False", "no open PR")` → защита ORCH-073 верно удержала задачу (HOLD, не ложный `done`), но лечила следствие. **Фикс (ADR-001, аддитивно, внутри того же под-гейта merge-verify, машина стадий не тронута):** (1) новый идемпотентный leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)` (never-raise): `GET …/pulls?state=open` с фильтром **`head.ref==branch` И `base.ref=="main"`** (идентичен `merge_pr`/ORCH-073 FR-3 — авто-docs-PR `base!=main` НЕ код-PR) → `("existed", N)`; иначе `POST …/pulls` → `("created", N)`; гонка `409/422` «PR exists» → повторный GET → `existed` (без дублей); любая иная HTTP/parse/сетевая ошибка → `("failed", reason)`. (2) Врезка в `stage_engine._handle_merge_verify` ПОСЛЕ резолва `validated_revision` и ПЕРЕД `merge_pr`: при `merge_verify_autocreate_pr_enabled` → `ensure_open_pr`; `created|existed` → штатно к `merge_pr` → `verify_merged_to_main`; `failed` → честный HOLD через новый helper `_hold_pr_create_failed` (текст «PR создать не удалось», `result.note="pr-create-failed-hold"` — текстуально отличим от not-merged HOLD; задача остаётся на `deploy`, НЕ `done`, БЕЗ отката на development). (3) `launcher._ensure_pr` делегирован в `merge_gate.ensure_open_pr` (единый код создания PR, общий фильтр `head==branch & base==main`); триггер «создавать только на developer-пути со свежим коммитом» НЕ ужесточён — менялась только реализация под капотом. **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО `verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` устраняет лишь ЛОЖНЫЙ HOLD «no open PR», реально невлитый код → HOLD как прежде. Kill-switch `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED` (дефолт `true`); область — `merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`), non-self → no-op; `false` → поведение ORCH-074 1:1. Идемпотентность из Gitea (наличие открытого PR), без миграции БД (restart-safe); `main` не push/force-push. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (под-гейт — врезка в `advance_stage`, не новый QG), схема БД, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058), внешние HTTP-эндпоинты. ADR `docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md` (+ сквозной `adr-0016`). Документация: `docs/architecture/README.md` (блок ORCH-082 в merge-verify). Тесты: `tests/test_orch082_ensure_pr.py` (TC-01..05: идемпотентный актор, фильтр base==main, гонка 409/422, never-raise), `tests/test_orch082_merge_verify_autocreate.py` (TC-06..12: врезка, регресс ORCH-073, kill-switch, условность, наблюдаемость). - **Устойчивость резолва `--effort` к пустому env + developer → `xhigh`** (ORCH-081/ORCH-52h): фикс конфигурационного бага, из-за которого в проде `resolve_agent_effort()` возвращал `''` для всех 6 агентов и `--effort` не передавался в Claude CLI (каждый агент бежал на встроенном CLI-дефолте вместо заявленного уровня — прямой удар по предсказуемости качества всего конвейера, включая enduro-trails из общего инстанса). **Корень:** pydantic Settings трактует ПРИСУТСТВУЮЩУЮ env-переменную, даже пустую (`ORCH_AGENT_EFFORT_*=` без значения), как явное `''` и перебивает class-default; в проде пусты И per-agent, И `agent_effort_default`, поэтому у цепочки резолва (`_resolve_agent_attr`: project-override → per-agent env → default → `''`) не остаётся непустого «пола» для отката. **Фикс (вариант c, ADR-001):** в `resolve_agent_effort` (`src/agents/launcher.py`) добавлен уровень 4 — непустой **per-role floor** ниже `default`: новый чистый helper `_agent_effort_floor(agent)` возвращает декларированный class-default поля `agent_effort_` через `type(settings).model_fields[...].default` (значение, которое пустой env перебить НЕ может). Floor срабатывает ТОЛЬКО когда уровни 1–3 пусты и применяется ДО валидации, поэтому: (а) при пустом прод-`.env` каждая роль получает СВОЙ канонический уровень (developer=`xhigh`, tester/deployer=`medium`, analyst/architect/reviewer=`high`), а не общий default; (б) явная опечатка (`turbo`/`ultra`) непуста → floor НЕ применяется → значение штатно дропается валидацией `VALID_EFFORTS` в `''` (never-break ORCH-41 не регрессирует, floor не маскирует мусор); (в) непустой явный env/project-override/`default` по-прежнему ПОБЕЖДАЕТ floor (приоритет резолва сохранён 1:1). Unknown-agent (имя вне 6 ролей) деградирует на class-default `agent_effort_default` (`high`) — безопасный непустой пол. **`config.py`:** `agent_effort_developer` `high → xhigh` (канон Opus 4.8: coding/agentic роль) — единственное изменение значений; floor подтягивает его автоматически (единый источник правды, ноль риска дрейфа floor-карты). Инварианты НЕ менялись: приоритеты/сигнатуры резолва ORCH-41, `_resolve_agent_attr` (общий с model-резолвом, не тронут), `resolve_agent_model` (ORCH-074), путь проброса `--effort` в `_spawn`, `VALID_EFFORTS`, API, схема БД (без миграций). ADR `docs/work-items/ORCH-081/06-adr/ADR-001-effort-resolution-floor.md`. Документация: `docs/architecture/README.md` (таблица «модель/эффорт по ролям»: developer `xhigh` + ремарка про floor), `.env.example` (`ORCH_AGENT_EFFORT_DEVELOPER=xhigh` + комментарий split/floor). Тесты: `tests/test_resolve_agent_effort.py` (TC-01..08: канон-дефолты, floor при пустом env per-role, floor-не-маскирует-typo, приоритет, `xhigh∈VALID_EFFORTS`, сборка флага `--effort xhigh`/`--effort medium`). - **Убран мёртвый frontmatter `model:` + валидация имени модели (never-break)** (ORCH-074): закрыты два дефекта данных/валидации каркаса выбора модели агентов (ORCH-41), без изменения механизма резолва, API или схемы БД. **G1 — мёртвый frontmatter:** из YAML-frontmatter всех 6 промптов `.openclaw/agents/*.md` удалена строка `model:` (`claude-sonnet-4-6` у analyst/developer/tester/deployer, `claude-opus-4-7` у architect/reviewer). launcher НЕ читал frontmatter `model:` — это была лживая/мёртвая декларация, противоречащая реально используемой модели (config) и принципу «документация = golden source»; мина: если бы кто-то «починил» launcher читать frontmatter, все агенты молча уехали бы на устаревшие модели. config (`agent_model_*`/`agent_model_default`) остаётся единственным источником правды; frontmatter описательный. **G2 — валидация имени модели:** добавлен чистый helper `is_valid_model(name)` + `_MODEL_NAME_RE` (`^claude-[a-z0-9.-]+$`) рядом с `VALID_EFFORTS` в `src/agents/launcher.py`. Резолвенное имя модели валидируется ПЕРЕД попаданием в `--model`: невалидное (опечатка, `gpt-4`, пустое, неверный префикс) → `logger.warning` + откат на следующий валидный уровень каскада ORCH-41 (project-override → env → default), в пределе → `""` (без флага `--model`, CLI-дефолт). Никогда не возвращается мусор и не бросается исключение (never-break, поведенческая аналогия `resolve_agent_effort`/`VALID_EFFORTS`). Выбран **формат-чек, а не allowlist `VALID_MODELS`**: allowlist воссоздаёт ровно ту мину, что убивается в G1 (статичный список врёт при устаревании — молча дропнул бы корректную будущую `claude-opus-4-9`); формат-чек forward-compatible (новые `claude-*` проходят без правки кода), финальный авторитет о существовании модели — сам Claude CLI. Тот же предикат применён к inline-чтению `--fallback-model` (`agent_fallback_model` читается напрямую в `_spawn`, мимо `resolve_agent_model` — TRZ §4), поэтому опечатка в `ORCH_AGENT_FALLBACK_MODEL` тоже дропается с warning; для текущего пустого значения регрессии нет. **G4 (fallback) НЕ включён** (`agent_fallback_model=""`, AC-5 N/A) — ради детерминизма (все агенты на `claude-opus-4-8`); **G3 (routing) НЕ включён** (AC-4 N/A) — осознанное решение стейкхолдера (Слава 08.06). Реализация `resolve_agent_model` рефакторнута на генератор кандидатов `_agent_model_candidates` (тот же приоритет ORCH-41) + валидация-со-скипом. Инварианты НЕ менялись: приоритеты/сигнатуры резолва ORCH-41, структура CLI-команды `_spawn`, `VALID_EFFORTS`-гард эффорта, `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД (без миграций); enduro per-project override валидные имена проходят без изменения поведения. ADR `docs/work-items/ORCH-074/06-adr/ADR-001-model-name-validation.md`. Документация: `docs/architecture/README.md` (таблица «модель/эффорт по ролям» + валидация), `CLAUDE.md`, `.env.example` (блок `ORCH_AGENT_MODEL_*`/`ORCH_AGENT_EFFORT_*`/`ORCH_AGENT_FALLBACK_MODEL`). Тесты: `tests/test_agent_frontmatter_no_model.py` (G1: TC-01/02), `tests/test_resolve_agent_model.py` (G2 never-break: TC-03..09, TC-11 + is_valid_model). diff --git a/CLAUDE.md b/CLAUDE.md index 2afcd8e..75d809b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,10 @@ created → analysis → architecture → development → review → testing → - **Кликабельный номер задачи** (`plane_issue_link`) — `ORCH-NNN` в карточке И во всех уведомлениях (`notify_*`, alert'ы стадий) рендерится как `` на issue в Plane; fail-safe → просто `html.escape(номер)`, если ссылку построить нельзя. Никогда не падает. +- **Без link-preview (ORCH-080):** оба примитива (`send_telegram`/`edit_telegram`) шлют + payload с `disable_web_page_preview: True` — баннер Plane («Modern project management») + под кликабельной ссылкой `ORCH-NNN` больше не разворачивается ни в карточке (`bump`/`edit`), + ни в notify/alert-сообщениях. `parse_mode: HTML` сохранён → ссылка остаётся кликабельной. - Транспорт (`send_telegram`/`edit_telegram`/`delete_telegram`), `disable_notification` (карточка тихая, пингуют только alert-хелперы), схема БД — не трогаются. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 995d13c..32e8d60 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -13,7 +13,7 @@ - **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота; декларации/циклы — leaf `src/task_deps.py`. - **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attemptsORCH-NNN`. + +Telegram по умолчанию разворачивает **link-preview** (web page preview) для первой ссылки +в сообщении. Из-за ссылки на Plane под каждым сообщением трекера раскрывается крупный +баннер-превью **«Plane — Modern project management»**. + +**Жалоба (Слава, 08.06):** баннер уродует ленту чата и дублируется на каждой задаче/каждом +обновлении карточки (особенно заметно в дефолтном режиме `bump`, где карточка пересоздаётся +на каждом переходе). + +## 2. Диагностика (код-аудит `src/notifications.py`) + +| Функция | Эндпоинт | Текущий JSON-payload | Превью | +|---------|----------|----------------------|--------| +| `send_telegram()` (стр. 52-62) | `POST /sendMessage` | `chat_id`, `text`, `parse_mode: HTML`, `disable_notification` | **разворачивается** (нет `disable_web_page_preview`) | +| `edit_telegram()` (стр. 165-174) | `POST /editMessageText` | `chat_id`, `message_id`, `text`, `parse_mode: HTML` | **разворачивается** (нет `disable_web_page_preview`) | + +Причина баннера: оба payload **не содержат** ключ `disable_web_page_preview`. Telegram Bot API +по умолчанию (отсутствие ключа) включает превью. + +`delete_telegram()` (`/deleteMessage`) превью не порождает — правки не требует. + +## 3. Бизнес-цель + +Карточка трекера и уведомления в Telegram **не должны** показывать баннер link-preview Plane, +при этом ссылка на задачу **остаётся кликабельной**. + +## 4. Бизнес-требования + +- **BR-1.** В payload `sendMessage` (`send_telegram`) присутствует `disable_web_page_preview: True`. +- **BR-2.** В payload `editMessageText` (`edit_telegram`) присутствует `disable_web_page_preview: True`. +- **BR-3.** Баннер-превью Plane больше не появляется ни под карточкой трекера (оба режима + `bump`/`edit`), ни под отдельными notify-сообщениями, которые идут через `send_telegram` + (`notify_approve_requested`, `notify_error`, alert'ы стадий) — все они используют тот же + низкоуровневый примитив. +- **BR-4.** Кликабельная ссылка `` на задачу в Plane сохраняется (`parse_mode: HTML` + не меняется). +- **BR-5.** Контракт **never-raise** сохранён: отправка/редактирование никогда не валит + оркестратор; `pytest` зелёный. + +## 5. Не-цели (вне скоупа) + +- Не менять текст/формат/верстку карточки. +- Не трогать `parse_mode` (HTML нужен для ``). +- Не трогать bump/edit-логику (`update_task_tracker`), репойнт `tracker_message_id`, + delete-семантику. +- Не вводить флаги/конфиг — поведение «без превью» безусловное (превью никому не нужно). +- Не трогать схему БД. + +## 6. Заинтересованные лица + +- **Слава (Owner)** — инициатор, конечный наблюдатель ленты Telegram. + +## 7. Грабли / координация + +- Файл `src/notifications.py` затрагивает также ORCH-067 (и потенциально другие задачи эпика). + Сверить, что правки (две строки) не конфликтуют при merge. +- Один репозиторий с ORCH-74 → по ORCH-026 действует сериализация merge. + Запускать **после** того как ORCH-74 доедет в `main` (или когда конвейер свободен), + чтобы не плодить параллельный merge в `orchestrator`. +- Деплой — штатный через **Confirm Deploy** (self-hosting, ORCH-059). diff --git a/docs/work-items/ORCH-080/02-trz.md b/docs/work-items/ORCH-080/02-trz.md new file mode 100644 index 0000000..d60278e --- /dev/null +++ b/docs/work-items/ORCH-080/02-trz.md @@ -0,0 +1,102 @@ +# 02-TRZ — ORCH-080: убрать Telegram link-preview в уведомлениях трекера + +Work Item ID: ORCH-080 +Зона изменений: `src/notifications.py` (две строки) + +## 1. Задействованные модули `src/` + +- `src/notifications.py` — **единственный** изменяемый модуль: + - `send_telegram(text, disable_notification=False)` — обёртка `POST .../sendMessage`. + - `edit_telegram(message_id, text)` — обёртка `POST .../editMessageText`. + +Косвенно затронуты (поведение улучшается без изменения их кода — они вызывают изменённые +примитивы): `update_task_tracker` (bump+edit), `notify_approve_requested`, `notify_error`, +а также вызовы `send_telegram` из `launcher`/`stage_engine` (alert'ы деплоя/падений). + +## 2. Изменения кода + +### 2.1. `send_telegram()` — добавить ключ в JSON-payload `httpx.post` + +В словаре `json={...}` вызова `sendMessage` (текущие стр. 55-60) добавить строку: + +```python +"disable_web_page_preview": True, +``` + +Итоговый payload: +```python +json={ + "chat_id": s.telegram_chat_id, + "text": text, + "parse_mode": "HTML", + "disable_notification": disable_notification, + "disable_web_page_preview": True, +}, +``` + +### 2.2. `edit_telegram()` — добавить ключ в JSON-payload `httpx.post` + +В словаре `json={...}` вызова `editMessageText` (текущие стр. 168-173) добавить строку: + +```python +"disable_web_page_preview": True, +``` + +Итоговый payload: +```python +json={ + "chat_id": s.telegram_chat_id, + "message_id": message_id, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": True, +}, +``` + +> Примечание: Telegram Bot API исторически принимает top-level `disable_web_page_preview` +> для `sendMessage`/`editMessageText` (актуальная схема также поддерживает +> `link_preview_options.is_disabled`, но top-level флаг остаётся валиден и совместим). +> Используем top-level флаг — минимальная, обратносовместимая правка, как указано в задаче. + +## 3. Изменения API + +Нет изменений внутреннего HTTP API оркестратора. Меняется только тело исходящих запросов к +Telegram Bot API (добавлен один булев ключ в payload двух методов). + +## 4. Изменения схемы БД + +Нет. + +## 5. Требования к новым QG checks + +Нет. Новые Quality Gate проверки не вводятся. + +## 6. Конфиг / флаги + +Нет. Поведение «без превью» — безусловное (kill-switch не требуется: превью трекера +не нужно никому, риск регрессии нулевой; правка обратимая одной строкой). +`parse_mode`, `disable_notification`, bump/edit-логика — без изменений. + +## 7. Артефакты, обновляемые по pipeline + +- `CHANGELOG.md` — запись в `## [Unreleased]` (тип `fix:` — косметика UX уведомлений). +- Документация: правка `src/notifications.py` затрагивает поведение, описанное в + `CLAUDE.md` (раздел «Нотификации / Telegram live-tracker») и + `docs/architecture/README.md` (компонент Notifications). Достаточно короткой ремарки, + что карточка/уведомления шлются без web-page-preview (по желанию архитектора — определить + объём в ADR; ADR не обязателен для столь малой косметики, решение за архитектором). + +## 8. Контракты-инварианты (не нарушать) + +- **never-raise**: обе функции по-прежнему ловят все исключения (`try/except: pass`/`return`) + и не валят оркестратор. +- Возвращаемые значения не меняются: `send_telegram` → `message_id|None`, + `edit_telegram` → `EDIT_*`. +- `parse_mode: "HTML"` сохранён в обоих payload (иначе `` сломается). +- `disable_notification` в `send_telegram` сохранён (карточка тихая). +- Инвариант «одна карточка на задачу» (bump/edit) не затрагивается. + +## 9. Commit / ветка + +- Ветка: `feature/ORCH-080-orch-52g-telegram-link-preview` (существует). +- Commit: `fix: disable Telegram link-preview in tracker notifications (ORCH-080)`. diff --git a/docs/work-items/ORCH-080/03-acceptance-criteria.md b/docs/work-items/ORCH-080/03-acceptance-criteria.md new file mode 100644 index 0000000..964f564 --- /dev/null +++ b/docs/work-items/ORCH-080/03-acceptance-criteria.md @@ -0,0 +1,59 @@ +# 03-Acceptance Criteria — ORCH-080 + +Work Item ID: ORCH-080 + +Каждый критерий имеет явное условие PASS/FAIL. + +## AC-1 — `disable_web_page_preview` в payload `sendMessage` + +- **PASS:** JSON-payload вызова `httpx.post(.../sendMessage)` в `send_telegram()` содержит + ключ `"disable_web_page_preview"` со значением `True`. +- **FAIL:** ключ отсутствует или `False`. +- **Проверка:** unit-тест (мок `httpx`) инспектирует `httpx.post.call_args.kwargs["json"]`. + +## AC-2 — `disable_web_page_preview` в payload `editMessageText` + +- **PASS:** JSON-payload вызова `httpx.post(.../editMessageText)` в `edit_telegram()` содержит + ключ `"disable_web_page_preview"` со значением `True`. +- **FAIL:** ключ отсутствует или `False`. +- **Проверка:** unit-тест (мок `httpx`) инспектирует `httpx.post.call_args.kwargs["json"]`. + +## AC-3 — баннер link-preview Plane исчез в карточке трекера + +- **PASS:** в реальном чате Telegram карточка трекера задачи (режимы `bump` и `edit`) + больше не показывает баннер «Plane — Modern project management». +- **FAIL:** баннер всё ещё разворачивается. +- **Проверка:** ручная верификация на staging (8501) после деплоя — наблюдение карточки в + Telegram. Автоматически косвенно покрыто AC-1/AC-2 (payload содержит флаг). + +## AC-4 — ссылка на задачу остаётся кликабельной + +- **PASS:** в карточке/уведомлениях номер задачи `ORCH-NNN` остаётся кликабельной ссылкой + `` на issue в Plane; `parse_mode: "HTML"` сохранён в обоих payload. +- **FAIL:** `parse_mode` изменён/удалён, либо ссылка перестала рендериться как ``. +- **Проверка:** unit-тест проверяет, что `"parse_mode": "HTML"` присутствует в обоих payload; + существующие тесты ссылок (`test_notify_issue_links.py`) остаются зелёными. + +## AC-5 — сохранены существующие поля payload + +- **PASS:** `send_telegram` payload по-прежнему содержит `chat_id`, `text`, `parse_mode`, + `disable_notification`; `edit_telegram` payload — `chat_id`, `message_id`, `text`, + `parse_mode`. Возвращаемые значения функций не изменились + (`send_telegram → message_id|None`, `edit_telegram → EDIT_*`). +- **FAIL:** любое из перечисленных полей удалено/переименовано, либо изменился контракт + возврата. +- **Проверка:** unit-тесты payload + существующие тесты трекера/классификации исходов. + +## AC-6 — never-raise сохранён, pytest зелёный + +- **PASS:** при сетевой/HTTP-ошибке `send_telegram`/`edit_telegram` не бросают исключение + (возврат `None`/`EDIT_FAILED`); вся сюита `pytest tests/ -q` зелёная. +- **FAIL:** любое исключение наружу или красный pytest. +- **Проверка:** существующие тесты never-raise (`test_resilience.py`, + `test_telegram_tracker.py`) + полный прогон. + +## AC-7 — документация обновлена в том же PR + +- **PASS:** `CHANGELOG.md` содержит запись об ORCH-080; при необходимости — короткая ремарка + в `CLAUDE.md`/`docs/architecture/README.md` о подавлении link-preview. +- **FAIL:** функционал изменён, документация не обновлена (Reviewer → REQUEST_CHANGES). diff --git a/docs/work-items/ORCH-080/04-test-plan.yaml b/docs/work-items/ORCH-080/04-test-plan.yaml new file mode 100644 index 0000000..5177196 --- /dev/null +++ b/docs/work-items/ORCH-080/04-test-plan.yaml @@ -0,0 +1,76 @@ +work_item: ORCH-080 +description: > + Подавление Telegram link-preview (disable_web_page_preview: True) в payload + send_telegram (sendMessage) и edit_telegram (editMessageText). Сохранить + parse_mode HTML, disable_notification, never-raise и контракты возврата. + +tests: + - id: TC-01 + type: unit + description: > + send_telegram() кладёт "disable_web_page_preview": True в JSON-payload + httpx.post(.../sendMessage). Проверка через мок httpx и инспекцию + httpx.post.call_args.kwargs["json"]. + module: tests/test_link_preview_disabled.py + expected: PASS + + - id: TC-02 + type: unit + description: > + edit_telegram() кладёт "disable_web_page_preview": True в JSON-payload + httpx.post(.../editMessageText). Проверка через мок httpx и инспекцию + payload. + module: tests/test_link_preview_disabled.py + expected: PASS + + - id: TC-03 + type: unit + description: > + Регрессия parse_mode: оба payload (sendMessage и editMessageText) + по-прежнему содержат "parse_mode": "HTML" — ссылка остаётся + кликабельной (AC-4). + module: tests/test_link_preview_disabled.py + expected: PASS + + - id: TC-04 + type: unit + description: > + Регрессия полей send_telegram: payload содержит chat_id, text, + parse_mode, disable_notification; disable_notification прокидывается + из аргумента (True/False) без изменений (AC-5). + module: tests/test_link_preview_disabled.py + expected: PASS + + - id: TC-05 + type: unit + description: > + Контракты возврата не изменились: send_telegram возвращает message_id + при ok:true, None при отсутствии креденшелов/ошибке; edit_telegram + возвращает EDIT_OK при ok:true (AC-5, AC-6). + module: tests/test_link_preview_disabled.py + expected: PASS + + - id: TC-06 + type: unit + description: > + never-raise: при httpx.post бросающем исключение send_telegram->None и + edit_telegram->EDIT_FAILED, без проброса исключения (AC-6). + module: tests/test_link_preview_disabled.py + expected: PASS + + - id: TC-07 + type: integration + description: > + Полный прогон существующей сюиты трекера/уведомлений остаётся зелёным + (нет регрессий bump/edit-логики, классификации исходов, ссылок): + pytest tests/test_telegram_tracker.py tests/test_tracker_bump.py + tests/test_notify_issue_links.py tests/test_resilience.py. + module: tests/test_telegram_tracker.py + expected: PASS + + - id: TC-08 + type: integration + description: > + Вся сюита pytest tests/ -q зелёная (общая регрессия, AC-6). + module: tests/ + expected: PASS diff --git a/docs/work-items/ORCH-080/06-adr/ADR-001-disable-telegram-link-preview.md b/docs/work-items/ORCH-080/06-adr/ADR-001-disable-telegram-link-preview.md new file mode 100644 index 0000000..0ff2088 --- /dev/null +++ b/docs/work-items/ORCH-080/06-adr/ADR-001-disable-telegram-link-preview.md @@ -0,0 +1,63 @@ +# ADR-001: Подавление Telegram link-preview в низкоуровневых примитивах нотификаций + +## Статус +Accepted + +## Контекст +С ORCH-067 карточка трекера и notify-сообщения несут кликабельный номер задачи +`ORCH-NNN`. Telegram +Bot API по умолчанию (при отсутствии ключа `disable_web_page_preview`) разворачивает +web-page-preview для первой ссылки в сообщении — под каждым сообщением трекера +раскрывается баннер «Plane — Modern project management». В дефолтном режиме `bump` +(ORCH-067) карточка пересоздаётся на каждом переходе, поэтому баннер дублируется на +каждой задаче и каждом обновлении, засоряя ленту (жалоба Owner, 08.06). + +Код-аудит (`src/notifications.py`) подтвердил причину: JSON-payload обоих +низкоуровневых примитивов — `send_telegram()` (`POST /sendMessage`, стр. 55-60) и +`edit_telegram()` (`POST /editMessageText`, стр. 168-173) — **не содержит** ключ +`disable_web_page_preview`. Все вышестоящие нотификации (`update_task_tracker` в обоих +режимах, `notify_approve_requested`, `notify_error`, alert'ы стадий из +`launcher`/`stage_engine`) проходят через эти два примитива. + +## Решение +Добавить `"disable_web_page_preview": True` в JSON-payload `httpx.post` обоих примитивов: +`send_telegram()` и `edit_telegram()`. Изменение — **на уровне низкоуровневого +примитива**, а не на уровне каждого вызова, потому что: + +1. **Единая точка** — все исходящие сообщения трекера/нотификаций идут через эти две + функции; правка двух строк гасит баннер у ВСЕХ потребителей (карточка `bump`/`edit`, + notify-хелперы, alert'ы) без изменения их кода. +2. **Безусловно, без флага** — превью Plane не нужно никому (это не данные, а навигация + по ссылке, которая остаётся кликабельной). Kill-switch не вводится: риск регрессии + нулевой, правка обратима одной строкой. Это согласуется с принципом «минимум + зависимостей/конфигурации». +3. **Top-level флаг, а не `link_preview_options.is_disabled`** — top-level + `disable_web_page_preview` остаётся валиден и обратносовместим в Bot API; это + минимальная правка без введения вложенной структуры. + +`parse_mode: "HTML"` сохраняется в обоих payload (иначе `` перестанет +рендериться — ссылка должна остаться кликабельной). `disable_notification`, +bump/edit-логика, repoint `tracker_message_id`, delete-семантика, контракты возврата +(`send_telegram → message_id|None`, `edit_telegram → EDIT_*`) — не затрагиваются. + +## Последствия +**Плюсы:** +- Баннер link-preview исчезает под карточкой трекера (оба режима) и под всеми + notify/alert-сообщениями — одна правда в двух примитивах. +- Ссылка на задачу остаётся кликабельной (HTML сохранён). +- Нулевой риск: ключ аддитивный, контракты примитивов и инвариант «одна карточка на + задачу» не меняются; `never-raise` (`try/except`) сохранён. + +**Минусы / ограничения:** +- Поведение безусловное — нет конфигурации «вернуть превью». Сознательный выбор: + превью трекера не имеет ценности, флаг был бы лишней поверхностью. + +**Не затрагивается:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД, `parse_mode`, +`disable_notification`, транспортные хелперы `delete_telegram`/repoint-логика. Глобальный +ADR не требуется — решение локально для `src/notifications.py`, не сквозное. + +## Self-hosting +Изменение не требует немедленного рестарта прод-контейнера и не меняет топологию. +Деплой — штатный через staging (8501) → `Confirm Deploy` (ORCH-059). По ORCH-026 +(сериализация merge одного репо) задача мержится после освобождения конвейера +`orchestrator` (координация с ORCH-074 — см. BRD §7). diff --git a/docs/work-items/ORCH-080/10-tech-risks.md b/docs/work-items/ORCH-080/10-tech-risks.md new file mode 100644 index 0000000..e68556c --- /dev/null +++ b/docs/work-items/ORCH-080/10-tech-risks.md @@ -0,0 +1,22 @@ +# 10-Tech Risks — ORCH-080 + +Work Item ID: ORCH-080 +Зона: `src/notifications.py` (две строки в `send_telegram`/`edit_telegram`) + +Косметическая правка UX (LOW). Топология, схема БД, стадии, QG — не меняются. +Риск регрессии оценён как **нулевой**; ниже — остаточные пункты для внимания. + +| # | Риск | Вероятность | Влияние | Митигация | +|---|------|-------------|---------|-----------| +| R-1 | Опечатка ключа/значения (`disable_web_page_preview`) — баннер не гаснет | Низкая | Низкое (косметика) | unit-тест AC-1/AC-2 инспектирует `httpx.post.call_args.kwargs["json"]`; ручная верификация на staging (AC-3) | +| R-2 | Случайное удаление `parse_mode: "HTML"` → ссылка `` ломается | Очень низкая | Среднее (теряется кликабельность) | AC-4: unit-тест на наличие `parse_mode: "HTML"` в обоих payload; `test_notify_issue_links.py` остаётся зелёным | +| R-3 | Merge-конфликт с ORCH-067/ORCH-074 в `src/notifications.py` | Низкая | Низкое | По ORCH-026 сериализация merge одного репо; запуск после доезда ORCH-74 в `main` (BRD §7); pre-merge rebase (ORCH-043) | +| R-4 | Регрессия контракта возврата примитивов (`message_id|None` / `EDIT_*`) | Очень низкая | Среднее | Правка строго аддитивна (новый ключ в payload), возвраты не трогаются; AC-5 + существующие тесты трекера | +| R-5 | Telegram депрекейтит top-level `disable_web_page_preview` в пользу `link_preview_options` | Очень низкая | Низкое (forward-compat) | Top-level флаг остаётся валиден и обратносовместим; миграция на `link_preview_options.is_disabled` — отдельная задача при необходимости | + +## Инварианты, которые НЕЛЬЗЯ нарушить +- `never-raise` обоих примитивов (`try/except` сохранён). +- `parse_mode: "HTML"` в обоих payload (иначе `` ломается). +- `disable_notification` в `send_telegram` (карточка тихая). +- Инвариант «одна карточка на задачу» (bump/edit) — не затрагивается. +- Контракты возврата: `send_telegram → message_id|None`, `edit_telegram → EDIT_*`. diff --git a/docs/work-items/ORCH-080/12-review.md b/docs/work-items/ORCH-080/12-review.md new file mode 100644 index 0000000..a13cc9a --- /dev/null +++ b/docs/work-items/ORCH-080/12-review.md @@ -0,0 +1,72 @@ +--- +type: review +work_item_id: ORCH-080 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-080 + +## Summary +Задача убирает баннер Telegram link-preview («Plane — Modern project management»), +который разворачивался под кликабельной ссылкой `ORCH-NNN` в карточке трекера и +во всех notify/alert-сообщениях. Решение точно соответствует TRZ и ADR-001: +добавлен ключ `"disable_web_page_preview": True` в JSON-payload обоих +низкоуровневых примитивов `send_telegram` (`POST /sendMessage`) и `edit_telegram` +(`POST /editMessageText`) — единая точка для всех потребителей, без kill-switch, +без изменения контрактов. Изменение минимально (2 строки + комментарии), +аддитивно и обратимо. + +Проверены все четыре оси (ТЗ, ADR, качество кода, тесты) + документация. Findings +уровней P0/P1/P2 — нет. + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +## Соответствие ТЗ и AC +- TRZ §2.1/§2.2 — ключ добавлен в оба payload в точности как предписано. ✅ +- AC-1 — `disable_web_page_preview: True` в `sendMessage` payload (TC-01). ✅ +- AC-2 — то же в `editMessageText` payload (TC-02). ✅ +- AC-3 — баннер исчезает (ручная верификация на staging; косвенно покрыто AC-1/AC-2). ✅ +- AC-4 — `parse_mode: "HTML"` сохранён в обоих payload, ссылка кликабельна (TC-03); + `tests/test_notify_issue_links.py` зелёный. ✅ +- AC-5 — поля `chat_id/text/parse_mode/disable_notification` (send) и + `chat_id/message_id/text/parse_mode` (edit) сохранены; контракты возврата + (`message_id|None`, `EDIT_*`) не изменились (TC-04/TC-05). ✅ +- AC-6 — never-raise сохранён (TC-06); полный прогон `pytest tests/ -q` — **1058 passed**. ✅ +- AC-7 — документация обновлена в том же PR (см. ниже). ✅ + +## Соответствие ADR +ADR-001 (Accepted): правка на уровне примитива (а не каждого вызова), безусловно +без флага, top-level `disable_web_page_preview` вместо `link_preview_options`, +`parse_mode: HTML` сохранён, контракты и инвариант «одна карточка на задачу» не +тронуты. Реализация соответствует решению 1:1. Глобальные ADR не нарушены +(`STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД — без изменений). ✅ + +## Качество кода +- Изменение минимальное, целевое; комментарии ссылаются на ORCH-080 и поясняют цель. +- `try/except` never-raise в обеих функциях не затронут; пути без кредов и контракты + возврата сохранены. +- Тесты содержательные: инспектируют реальный payload через мок `httpx` + (`call_args.kwargs["json"]`), покрывают флаг, регрессию `parse_mode`/полей, + контракты возврата и never-raise (TC-01..06). Нет тривиальных/пустых тестов. +- Security: ключ булев, новых поверхностей/секретов нет. + +## Документация +Изменён `src/` (поведение исходящих Telegram-запросов) → документация обновлена в +том же PR, как требует CLAUDE.md §2/§6: +- `CHANGELOG.md` — запись в `## [Unreleased]` (тип `fix:`). ✅ +- `CLAUDE.md` — раздел «Нотификации / Telegram live-tracker» дополнен пунктом + «Без link-preview (ORCH-080)». ✅ +- `docs/architecture/README.md` — компонент Notifications дополнен ремаркой ORCH-080. ✅ +- ADR `docs/work-items/ORCH-080/06-adr/ADR-001-disable-telegram-link-preview.md` заведён. ✅ + +Документация соответствует коду; расхождений нет. diff --git a/docs/work-items/ORCH-080/13-test-report.md b/docs/work-items/ORCH-080/13-test-report.md new file mode 100644 index 0000000..6a86dbc --- /dev/null +++ b/docs/work-items/ORCH-080/13-test-report.md @@ -0,0 +1,66 @@ +--- +type: test-report +work_item_id: ORCH-080 +result: PASS +--- + +# Test Report — ORCH-080 + +Подавление Telegram link-preview (`disable_web_page_preview: True`) в `send_telegram` +(`sendMessage`) и `edit_telegram` (`editMessageText`). Сохранены `parse_mode: HTML`, +`disable_notification`, never-raise и контракты возврата. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Дата: 2026-06-09 +- Ветка: `feature/ORCH-080-orch-52g-telegram-link-preview` +- Review verdict: APPROVED (`12-review.md`) + +## Smoke test API (prod 8500, read-only) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK | +| `GET /status` | OK (ORCH-080 = task #62, stage `testing`) | +| `GET /queue` | OK (breaker `closed`, preflight_ok, reconcile/reaper enabled) | + +## Результаты тестов + +| TC ID | Описание | Тест(ы) | Результат | +|-------|----------|---------|-----------| +| TC-01 | `disable_web_page_preview: True` в payload `sendMessage` (AC-1) | `test_send_telegram_disables_link_preview` | PASS | +| TC-02 | `disable_web_page_preview: True` в payload `editMessageText` (AC-2) | `test_edit_telegram_disables_link_preview` | PASS | +| TC-03 | Регрессия `parse_mode: HTML` в обоих payload (AC-4) | `test_send_telegram_keeps_parse_mode_html`, `test_edit_telegram_keeps_parse_mode_html` | PASS | +| TC-04 | Регрессия полей `send_telegram` + проброс `disable_notification` (AC-5) | `test_send_telegram_preserves_existing_fields`, `test_send_telegram_disable_notification_default_false`, `test_edit_telegram_preserves_existing_fields` | PASS | +| TC-05 | Контракты возврата (`message_id`/`None`/`EDIT_OK`) (AC-5/AC-6) | `test_send_telegram_returns_message_id`, `test_send_telegram_returns_none_without_creds`, `test_edit_telegram_returns_edit_ok` | PASS | +| TC-06 | never-raise → `None`/`EDIT_FAILED` без проброса (AC-6) | `test_send_telegram_never_raises`, `test_edit_telegram_never_raises` | PASS | +| TC-07 | Регресс сюиты трекера/уведомлений (bump/edit, ссылки, resilience) | `test_telegram_tracker.py`, `test_tracker_bump.py`, `test_notify_issue_links.py`, `test_resilience.py` (+ `test_link_preview_disabled.py`) — 106 passed | PASS | +| TC-08 | Полная регрессия `pytest tests/ -q` (AC-6) | вся сюита — 1058 passed | PASS | + +## Покрытие Acceptance Criteria +- AC-1 — TC-01 ✅ +- AC-2 — TC-02 ✅ +- AC-3 (баннер исчез в чате) — ручная верификация на staging (8501) после деплоя; автоматически косвенно покрыто AC-1/AC-2 (payload несёт флаг). Не блокирует тест-гейт. +- AC-4 — TC-03 + `test_notify_issue_links.py` зелёный ✅ +- AC-5 — TC-04/TC-05 ✅ +- AC-6 — TC-06 + полный прогон зелёный ✅ +- AC-7 — документация (CHANGELOG/CLAUDE.md/architecture/ADR) проверена на review-стадии ✅ + +## Вывод pytest + +Полная сюита: +``` +1058 passed, 1 warning in 26.61s +``` + +Целевые файлы ORCH-080 (TC-01..07): +``` +106 passed, 1 warning in 3.24s +``` +(`test_link_preview_disabled.py` — 12 passed.) + +Единственный warning — `PydanticDeprecatedSince20` в `src/config.py:5` (предсуществующий, не связан с ORCH-080). + +## Итог +**PASS** — все автоматические тесты (TC-01..08) зелёные, smoke API OK, регрессий нет. +Задача готова к переходу на стадию `deploy-staging`. diff --git a/docs/work-items/ORCH-080/14-deploy-log.md b/docs/work-items/ORCH-080/14-deploy-log.md new file mode 100644 index 0000000..03fd512 --- /dev/null +++ b/docs/work-items/ORCH-080/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-080 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/src/notifications.py b/src/notifications.py index 1af7dad..a0d6bc7 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -57,6 +57,9 @@ def send_telegram(text: str, disable_notification: bool = False): "text": text, "parse_mode": "HTML", "disable_notification": disable_notification, + # ORCH-080: suppress the Plane link-preview banner that Telegram + # would otherwise expand under every tracker card / notification. + "disable_web_page_preview": True, }, timeout=5, ) @@ -170,6 +173,8 @@ def edit_telegram(message_id: int, text: str) -> str: "message_id": message_id, "text": text, "parse_mode": "HTML", + # ORCH-080: suppress the Plane link-preview banner (see send_telegram). + "disable_web_page_preview": True, }, timeout=5, ) diff --git a/tests/test_link_preview_disabled.py b/tests/test_link_preview_disabled.py new file mode 100644 index 0000000..07f3e7a --- /dev/null +++ b/tests/test_link_preview_disabled.py @@ -0,0 +1,159 @@ +"""ORCH-080 — suppress Telegram link-preview in tracker/notify primitives. + +Both low-level primitives ``send_telegram`` (POST /sendMessage) and +``edit_telegram`` (POST /editMessageText) must add +``"disable_web_page_preview": True`` to their JSON payload, so the Plane +"Modern project management" banner no longer expands under every tracker card / +notification. The clickable issue link must stay clickable -> ``parse_mode: +"HTML"`` is preserved in both payloads, and the never-raise / return contracts +are unchanged. + +Network is isolated: ``src.notifications.httpx`` is patched; creds are stubbed. +Test ids TC-01..TC-06 from 04-test-plan.yaml. +""" + +import os +import tempfile +from unittest.mock import MagicMock, patch + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_link_preview.db") +os.environ.setdefault("ORCH_DB_PATH", _test_db) + +from src import notifications as N # noqa: E402 + +# conftest._no_telegram autouse-patches src.notifications.send_telegram to a +# no-op for every test (prod-leak guard). Capture the REAL implementation at +# import time (before any fixture runs) so these payload tests can exercise it. +_REAL_SEND = N.send_telegram + + +def _patch_tg_creds(monkeypatch): + monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "T", raising=False) + monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "C", raising=False) + + +def _ok_resp(message_id=42): + resp = MagicMock() + resp.json.return_value = {"ok": True, "result": {"message_id": message_id}} + return resp + + +# --------------------------------------------------------------------------- # +# TC-01 — send_telegram sets disable_web_page_preview: True +# --------------------------------------------------------------------------- # +def test_send_telegram_disables_link_preview(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + _REAL_SEND("hello") + payload = hx.post.call_args.kwargs["json"] + assert payload["disable_web_page_preview"] is True + + +# --------------------------------------------------------------------------- # +# TC-02 — edit_telegram sets disable_web_page_preview: True +# --------------------------------------------------------------------------- # +def test_edit_telegram_disables_link_preview(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + N.edit_telegram(1, "hello") + payload = hx.post.call_args.kwargs["json"] + assert payload["disable_web_page_preview"] is True + + +# --------------------------------------------------------------------------- # +# TC-03 — parse_mode HTML preserved in both payloads (clickable ) +# --------------------------------------------------------------------------- # +def test_send_telegram_keeps_parse_mode_html(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + _REAL_SEND("hello") + assert hx.post.call_args.kwargs["json"]["parse_mode"] == "HTML" + + +def test_edit_telegram_keeps_parse_mode_html(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + N.edit_telegram(1, "hello") + assert hx.post.call_args.kwargs["json"]["parse_mode"] == "HTML" + + +# --------------------------------------------------------------------------- # +# TC-04 — send_telegram preserves existing fields + disable_notification arg +# --------------------------------------------------------------------------- # +def test_send_telegram_preserves_existing_fields(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + _REAL_SEND("body", disable_notification=True) + payload = hx.post.call_args.kwargs["json"] + assert payload["chat_id"] == "C" + assert payload["text"] == "body" + assert payload["parse_mode"] == "HTML" + assert payload["disable_notification"] is True + + +def test_send_telegram_disable_notification_default_false(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + _REAL_SEND("body") + assert hx.post.call_args.kwargs["json"]["disable_notification"] is False + + +def test_edit_telegram_preserves_existing_fields(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + N.edit_telegram(7, "body") + payload = hx.post.call_args.kwargs["json"] + assert payload["chat_id"] == "C" + assert payload["message_id"] == 7 + assert payload["text"] == "body" + assert payload["parse_mode"] == "HTML" + + +# --------------------------------------------------------------------------- # +# TC-05 — return contracts unchanged +# --------------------------------------------------------------------------- # +def test_send_telegram_returns_message_id(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp(message_id=99) + assert _REAL_SEND("x") == 99 + + +def test_send_telegram_returns_none_without_creds(monkeypatch): + monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "", raising=False) + monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "", raising=False) + assert _REAL_SEND("x") is None + + +def test_edit_telegram_returns_edit_ok(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + assert N.edit_telegram(1, "x") == N.EDIT_OK + + +# --------------------------------------------------------------------------- # +# TC-06 — never-raise: httpx.post raising -> None / EDIT_FAILED +# --------------------------------------------------------------------------- # +def test_send_telegram_never_raises(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.side_effect = Exception("boom") + assert _REAL_SEND("x") is None + + +def test_edit_telegram_never_raises(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.side_effect = Exception("boom") + assert N.edit_telegram(1, "x") == N.EDIT_FAILED