---
tags:
  - type/plan
  - topic/vibe-coding
  - project/mars-bot
date: 2026-06-09
---

# mars-bot: упоминания каналов — план имплементации

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Добавить в существующий репо `mars-bot` фичу: периодически опрашивать TGStat `channels/mentions`, дедупить через локальную SQLite, слать отбивки в соответствующий чат-алиас через уже работающую отправлялку.

**Architecture:** Новый `bot.py` рядом с `cli.py`, один прогон = один вызов `mars-bot-mentions check`. Делит `.env`, `chats.json`, токен и `telegram.py` с CLI. Деплой — systemd timer на Pi (каждые 4 часа). Без long-running daemon.

**Tech Stack:** Python 3.11, `requests`, `python-dotenv`, `sqlite3` (stdlib), `pytest` + `pytest-mock`. Без новых зависимостей кроме того, что уже стоит.

**Spec:** `docs/{vibe-coding} {plan} mars-bot mentions дизайн – 2026-06-09.md`

**Все команды выполняются из корня репо** `projects/vibe-coding/mars-bot/`, если не указано иное. Все пути в задачах — относительно этого корня.

---

## Файловая раскладка (что создаём / меняем)

**Создаём:**
- `src/mars_bot/tgstat.py` — HTTP-клиент `channels/mentions` + нормализация username + self-mention хелпер
- `src/mars_bot/mentions_db.py` — SQLite: init, insert_or_ignore, mark_alerted, get_unalerted, is_cold_start
- `src/mars_bot/alert.py` — рендер карточки + format_subscribers
- `src/mars_bot/bot.py` — оркестратор: argparse `check` + `--dry-run`, cold start, основной цикл, pre-pass добивает alerted=0
- `scripts/mars-bot-mentions` — обёртка по образцу `scripts/mars-bot`
- `tests/test_tgstat.py`
- `tests/test_mentions_db.py`
- `tests/test_alert.py`
- `tests/test_bot.py`
- `docs/systemd/mars-bot-mentions.service`
- `docs/systemd/mars-bot-mentions.timer`

**Меняем:**
- `src/mars_bot/config.py` — `_mentions` блок проходит мимо int-валидации, добавляется `MentionsConfig` + `load_mentions_config()`
- `tests/test_config.py` — дописываем кейсы для `_mentions` блока
- `README.md` — секция про mentions, как запустить, как задеплоить

**Не трогаем в этом MVP:**
- `chats.json` (его контент — деплой-вопрос, не code change; `_mentions` блок добавит Наташа руками на dev и Pi)
- `.env` (`TGSTAT_TOKEN` руками)

---

## Task 1: Config — backward compat для `_mentions` блока

Этот таск идёт первым: без него любая попытка добавить `_mentions` в `chats.json` сломает существующий `mars-bot send`.

**Files:**
- Modify: `src/mars_bot/config.py`
- Modify: `tests/test_config.py`

- [ ] **Step 1.1: Дописать failing test для `_mentions`-фильтра**

В `tests/test_config.py` добавить в конец файла:

```python
def test_load_config_skips_underscore_keys(tmp_path: Path):
    """Ключи с префиксом '_' — служебные блоки, не чаты. Не валидировать как int."""
    (tmp_path / ".env").write_text("TELEGRAM_BOT_TOKEN=t\n")
    chats = {
        "team": -100123,
        "test": 42,
        "_mentions": {"tracked": {"marsingru": "team"}},
    }
    (tmp_path / "chats.json").write_text(json.dumps(chats))

    cfg = load_config(tmp_path)
    assert cfg.chats == {"team": -100123, "test": 42}
    assert "_mentions" not in cfg.chats
```

- [ ] **Step 1.2: Прогнать тест — должен упасть**

Run: `.venv/bin/pytest tests/test_config.py::test_load_config_skips_underscore_keys -v`
Expected: FAIL, `ConfigError: chat_id for '_mentions' must be int, got dict`

- [ ] **Step 1.3: Поправить `config.py` — фильтр underscore-ключей**

Заменить блок валидации (lines 43-47) в `src/mars_bot/config.py`:

```python
    # Filter out service blocks (keys with '_' prefix) before int validation.
    # Service blocks like '_mentions' use nested structures and are read separately.
    chat_aliases = {k: v for k, v in chats_raw.items() if not k.startswith("_")}
    if not chat_aliases:
        raise ConfigError("chats.json has no chat aliases (only service blocks)")
    for alias, chat_id in chat_aliases.items():
        if not isinstance(alias, str) or not alias:
            raise ConfigError(f"invalid alias in chats.json: {alias!r}")
        if not isinstance(chat_id, int):
            raise ConfigError(f"chat_id for {alias!r} must be int, got {type(chat_id).__name__}")

    return Config(bot_token=token, chats=chat_aliases)
```

Также удалить старую проверку `if not chats_raw:` (заменена на `if not chat_aliases:` выше).

- [ ] **Step 1.4: Прогнать тест — должен пройти**

Run: `.venv/bin/pytest tests/test_config.py::test_load_config_skips_underscore_keys -v`
Expected: PASS

- [ ] **Step 1.5: Прогнать ВСЕ тесты config — старые не сломались**

Run: `.venv/bin/pytest tests/test_config.py -v`
Expected: все PASS (включая `test_load_config_empty_chats` — пустой `{}` всё ещё ошибка)

- [ ] **Step 1.6: Дописать failing test для `load_mentions_config`**

В `tests/test_config.py` добавить:

```python
from mars_bot.config import load_mentions_config, MentionsConfig


def test_load_mentions_config_happy(tmp_path: Path):
    chats = {
        "team": -100123,
        "test": 42,
        "_mentions": {"tracked": {"marsingru": "team", "natashhhh": "test"}},
    }
    (tmp_path / "chats.json").write_text(json.dumps(chats))

    mc = load_mentions_config(tmp_path)
    assert isinstance(mc, MentionsConfig)
    assert mc.tracked == {"marsingru": "team", "natashhhh": "test"}


def test_load_mentions_config_returns_none_when_no_block(tmp_path: Path):
    (tmp_path / "chats.json").write_text('{"team": 1}')
    assert load_mentions_config(tmp_path) is None


def test_load_mentions_config_unknown_destination(tmp_path: Path):
    chats = {
        "team": -100123,
        "_mentions": {"tracked": {"marsingru": "nonexistent"}},
    }
    (tmp_path / "chats.json").write_text(json.dumps(chats))
    with pytest.raises(ConfigError, match="unknown destination alias 'nonexistent'"):
        load_mentions_config(tmp_path)


def test_load_mentions_config_missing_tracked_key(tmp_path: Path):
    chats = {"team": 1, "_mentions": {"something_else": {}}}
    (tmp_path / "chats.json").write_text(json.dumps(chats))
    with pytest.raises(ConfigError, match="_mentions block missing 'tracked'"):
        load_mentions_config(tmp_path)
```

- [ ] **Step 1.7: Прогнать — должны упасть**

Run: `.venv/bin/pytest tests/test_config.py -v -k mentions`
Expected: 4 FAIL, `ImportError: cannot import name 'load_mentions_config'`

- [ ] **Step 1.8: Имплементить `load_mentions_config` и `MentionsConfig`**

В `src/mars_bot/config.py` добавить (после `Config`):

```python
@dataclass(frozen=True)
class MentionsConfig:
    tracked: Dict[str, str]  # username (без @) → destination alias


def load_mentions_config(project_root: Path) -> "MentionsConfig | None":
    """Прочитать _mentions блок из chats.json. None, если блока нет."""
    chats_path = project_root / "chats.json"
    if not chats_path.exists():
        raise ConfigError(f"chats.json not found at {chats_path}")
    try:
        chats_raw = json.loads(chats_path.read_text())
    except json.JSONDecodeError as e:
        raise ConfigError(f"chats.json is not valid JSON: {e}")

    block = chats_raw.get("_mentions")
    if block is None:
        return None
    if not isinstance(block, dict):
        raise ConfigError("_mentions block must be an object")

    tracked = block.get("tracked")
    if tracked is None:
        raise ConfigError("_mentions block missing 'tracked' key")
    if not isinstance(tracked, dict):
        raise ConfigError("_mentions.tracked must be an object {username: alias}")

    # Validate destinations point to real aliases.
    aliases = {k for k in chats_raw if not k.startswith("_")}
    for username, dest in tracked.items():
        if not isinstance(username, str) or not username:
            raise ConfigError(f"invalid tracked username: {username!r}")
        if not isinstance(dest, str) or not dest:
            raise ConfigError(f"invalid destination for {username!r}: {dest!r}")
        if dest not in aliases:
            raise ConfigError(f"unknown destination alias {dest!r} for {username!r}")

    return MentionsConfig(tracked=tracked)
```

Также добавить `from typing import Optional` если используется (или оставить `"MentionsConfig | None"` строкой как сейчас — Python 3.11 OK).

- [ ] **Step 1.9: Прогнать все mentions-тесты — должны пройти**

Run: `.venv/bin/pytest tests/test_config.py -v -k mentions`
Expected: 4 PASS

- [ ] **Step 1.10: Прогнать ВСЕ тесты — ничего не сломано**

Run: `.venv/bin/pytest -v`
Expected: 71 (старых) + 5 (новых) = 76 PASS

- [ ] **Step 1.11: Коммит**

```bash
git add src/mars_bot/config.py tests/test_config.py
git commit -m "feat(config): support _mentions block in chats.json (backward compat)"
```

---

## Task 2: TGStat-клиент

**Files:**
- Create: `src/mars_bot/tgstat.py`
- Create: `tests/test_tgstat.py`

- [ ] **Step 2.1: Failing test — happy path + dataclasses**

Создать `tests/test_tgstat.py`:

```python
import pytest
from mars_bot.tgstat import (
    get_mentions,
    normalize_username,
    is_self_mention,
    TGStatError,
)


def _mock_response(monkeypatch, json_data, status_code=200):
    """Простой мок requests.get → объект с .json()/.raise_for_status()."""
    class FakeResp:
        def __init__(self):
            self.status_code = status_code
        def json(self):
            return json_data
        def raise_for_status(self):
            if self.status_code >= 400:
                import requests
                raise requests.HTTPError(f"{self.status_code}")
    import mars_bot.tgstat as mod
    captured = {}
    def fake_get(url, params=None, timeout=None):
        captured["url"] = url
        captured["params"] = params
        return FakeResp()
    monkeypatch.setattr(mod.requests, "get", fake_get)
    return captured


def test_get_mentions_happy(monkeypatch):
    sample = {
        "status": "ok",
        "response": {
            "items": [
                {"mentionId": 1, "mentionType": "channel", "postId": 100,
                 "postLink": "https://t.me/x/1", "postDate": 1700000000, "channelId": 9},
            ],
            "channels": [
                {"id": 9, "username": "@x", "title": "X", "participants_count": 10,
                 "link": "t.me/x"},
            ],
        },
    }
    captured = _mock_response(monkeypatch, sample)
    result = get_mentions(token="tok", channel_id="@marsingru", extended=True, limit=50)

    assert result["items"][0]["postId"] == 100
    assert result["channels"][0]["username"] == "@x"
    assert captured["url"] == "https://api.tgstat.ru/channels/mentions"
    assert captured["params"]["token"] == "tok"
    assert captured["params"]["channelId"] == "@marsingru"
    assert captured["params"]["extended"] == 1
    assert captured["params"]["limit"] == 50


def test_get_mentions_raises_on_status_not_ok(monkeypatch):
    _mock_response(monkeypatch, {"status": "error", "error": "bad token"})
    with pytest.raises(TGStatError, match="TGStat error: bad token"):
        get_mentions(token="bad", channel_id="@x")


def test_get_mentions_raises_on_http_error(monkeypatch):
    import requests
    import mars_bot.tgstat as mod
    def boom(url, params=None, timeout=None):
        raise requests.ConnectionError("network down")
    monkeypatch.setattr(mod.requests, "get", boom)
    with pytest.raises(TGStatError, match="HTTP request failed"):
        get_mentions(token="t", channel_id="@x")


def test_normalize_username():
    assert normalize_username("@MarsInGru") == "marsingru"
    assert normalize_username("marsingru") == "marsingru"
    assert normalize_username("  @MarsInGru  ") == "marsingru"
    assert normalize_username("") is None
    assert normalize_username(None) is None
    assert normalize_username("@") is None  # only sigil → effectively empty


def test_is_self_mention_true_cases():
    tracked = {"marsingru", "choooooooir", "natashhhh"}
    assert is_self_mention("@marsingru", tracked) is True
    assert is_self_mention("MARSINGRU", tracked) is True
    assert is_self_mention("  @MarsInGru  ", tracked) is True


def test_is_self_mention_false_cases():
    tracked = {"marsingru"}
    assert is_self_mention("@somebody", tracked) is False
    assert is_self_mention(None, tracked) is False  # неизвестный источник — НЕ self
    assert is_self_mention("", tracked) is False
```

- [ ] **Step 2.2: Прогнать — должны упасть**

Run: `.venv/bin/pytest tests/test_tgstat.py -v`
Expected: ImportError или collection error.

- [ ] **Step 2.3: Имплементить `tgstat.py`**

Создать `src/mars_bot/tgstat.py`:

```python
"""TGStat API client — channels/mentions endpoint."""
from typing import Iterable, Optional

import requests

API_BASE = "https://api.tgstat.ru"
DEFAULT_TIMEOUT = 30


class TGStatError(Exception):
    """Raised on TGStat HTTP error or status != ok."""


def get_mentions(
    token: str,
    channel_id: str,
    extended: bool = True,
    limit: int = 50,
    start_date: Optional[int] = None,
) -> dict:
    """Call channels/mentions, return the response object (with items[] and channels[])."""
    params = {
        "token": token,
        "channelId": channel_id,
        "limit": limit,
        "extended": 1 if extended else 0,
    }
    if start_date is not None:
        params["startDate"] = start_date

    url = f"{API_BASE}/channels/mentions"
    try:
        resp = requests.get(url, params=params, timeout=DEFAULT_TIMEOUT)
        resp.raise_for_status()
    except requests.RequestException as e:
        raise TGStatError(f"HTTP request failed: {e}") from e

    try:
        data = resp.json()
    except ValueError as e:
        raise TGStatError(f"TGStat returned non-JSON ({resp.status_code})") from e

    if data.get("status") != "ok":
        err = data.get("error") or data.get("message") or "unknown error"
        raise TGStatError(f"TGStat error: {err}")

    return data.get("response", {})


def normalize_username(s: Optional[str]) -> Optional[str]:
    """Стрипает пробелы, ведёт @, приводит к lower-case. None/пусто → None."""
    if s is None:
        return None
    cleaned = s.strip().lstrip("@").lower()
    return cleaned if cleaned else None


def is_self_mention(source_username: Optional[str], tracked_usernames: Iterable[str]) -> bool:
    """True если source_username нормализуется в один из tracked. None/пусто → False."""
    norm = normalize_username(source_username)
    if norm is None:
        return False
    tracked_norm = {normalize_username(u) for u in tracked_usernames}
    return norm in tracked_norm
```

- [ ] **Step 2.4: Прогнать тесты — должны пройти**

Run: `.venv/bin/pytest tests/test_tgstat.py -v`
Expected: 6 PASS.

- [ ] **Step 2.5: Прогнать всё**

Run: `.venv/bin/pytest -v`
Expected: 82 PASS (76 + 6).

- [ ] **Step 2.6: Коммит**

```bash
git add src/mars_bot/tgstat.py tests/test_tgstat.py
git commit -m "feat(tgstat): channels/mentions client + username normalization"
```

---

## Task 3: SQLite — `mentions_db` модуль

**Files:**
- Create: `src/mars_bot/mentions_db.py`
- Create: `tests/test_mentions_db.py`

- [ ] **Step 3.1: Failing tests для init + cold start**

Создать `tests/test_mentions_db.py`:

```python
import sqlite3
from pathlib import Path

import pytest
from mars_bot.mentions_db import (
    is_cold_start,
    init,
    insert_or_ignore,
    mark_alerted,
    get_unalerted,
)


def test_is_cold_start_true_when_missing(tmp_path: Path):
    db_path = tmp_path / "mentions.db"
    assert is_cold_start(db_path) is True


def test_is_cold_start_false_when_exists(tmp_path: Path):
    db_path = tmp_path / "mentions.db"
    db_path.write_bytes(b"")  # просто наличие файла
    assert is_cold_start(db_path) is False


def test_init_creates_schema(tmp_path: Path):
    db_path = tmp_path / "mentions.db"
    init(db_path)
    assert db_path.exists()
    with sqlite3.connect(db_path) as conn:
        rows = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name='mentions'"
        ).fetchall()
        assert rows == [("mentions",)]


def test_init_is_idempotent(tmp_path: Path):
    db_path = tmp_path / "mentions.db"
    init(db_path)
    init(db_path)  # не должно бросить
```

- [ ] **Step 3.2: Failing tests для insert/mark/get**

Дописать в `tests/test_mentions_db.py`:

```python
SAMPLE_ROW = {
    "post_id": 12345,
    "mars_channel": "marsingru",
    "mention_type": "channel",
    "source_channel_id": 9999,
    "source_username": "somechannel",
    "source_title": "Some Channel",
    "source_subscribers": 5000,
    "posted_at": 1700000000,
    "found_at": 1700000100,
    "post_link": "https://t.me/somechannel/42",
}


def _conn(tmp_path: Path) -> sqlite3.Connection:
    db_path = tmp_path / "mentions.db"
    init(db_path)
    return sqlite3.connect(db_path)


def test_insert_or_ignore_new_returns_true(tmp_path: Path):
    with _conn(tmp_path) as conn:
        assert insert_or_ignore(conn, SAMPLE_ROW) is True
        rows = conn.execute("SELECT post_id FROM mentions").fetchall()
        assert rows == [(12345,)]


def test_insert_or_ignore_duplicate_returns_false(tmp_path: Path):
    with _conn(tmp_path) as conn:
        insert_or_ignore(conn, SAMPLE_ROW)
        assert insert_or_ignore(conn, SAMPLE_ROW) is False
        rows = conn.execute("SELECT COUNT(*) FROM mentions").fetchall()
        assert rows == [(1,)]


def test_mark_alerted_updates_flag(tmp_path: Path):
    with _conn(tmp_path) as conn:
        insert_or_ignore(conn, SAMPLE_ROW)
        mark_alerted(conn, post_id=12345)
        flag = conn.execute("SELECT alerted FROM mentions WHERE post_id=12345").fetchone()
        assert flag == (1,)


def test_get_unalerted_returns_only_zero(tmp_path: Path):
    row2 = {**SAMPLE_ROW, "post_id": 22222}
    with _conn(tmp_path) as conn:
        insert_or_ignore(conn, SAMPLE_ROW)
        insert_or_ignore(conn, row2)
        mark_alerted(conn, post_id=12345)
        rows = get_unalerted(conn)
        assert [r["post_id"] for r in rows] == [22222]
        assert rows[0]["source_username"] == "somechannel"


def test_insert_with_cold_alerted_flag(tmp_path: Path):
    """Cold start использует alerted=1 при вставке — проверяем что флаг принимается."""
    with _conn(tmp_path) as conn:
        insert_or_ignore(conn, SAMPLE_ROW, alerted=1)
        flag = conn.execute("SELECT alerted FROM mentions WHERE post_id=12345").fetchone()
        assert flag == (1,)
```

- [ ] **Step 3.3: Прогнать — должны упасть**

Run: `.venv/bin/pytest tests/test_mentions_db.py -v`
Expected: ImportError.

- [ ] **Step 3.4: Имплементить `mentions_db.py`**

Создать `src/mars_bot/mentions_db.py`:

```python
"""SQLite-хранилище упоминаний: dedup по post_id, флаг alerted."""
import sqlite3
from pathlib import Path
from typing import Iterable, Optional


SCHEMA = """
CREATE TABLE IF NOT EXISTS mentions (
  post_id            INTEGER PRIMARY KEY,
  mars_channel       TEXT NOT NULL,
  mention_type       TEXT NOT NULL,
  source_channel_id  INTEGER,
  source_username    TEXT,
  source_title       TEXT,
  source_subscribers INTEGER,
  posted_at          INTEGER NOT NULL,
  found_at           INTEGER NOT NULL,
  post_link          TEXT NOT NULL,
  alerted            INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_posted_at ON mentions(posted_at);
"""


def is_cold_start(db_path: Path) -> bool:
    """True если файл БД ещё не создан — значит это самый первый прогон."""
    return not db_path.exists()


def init(db_path: Path) -> None:
    """Создаёт файл и схему. Идемпотентно."""
    db_path.parent.mkdir(parents=True, exist_ok=True)
    with sqlite3.connect(db_path) as conn:
        conn.executescript(SCHEMA)


def insert_or_ignore(
    conn: sqlite3.Connection,
    row: dict,
    alerted: int = 0,
) -> bool:
    """INSERT OR IGNORE по post_id. True если вставили, False если уже было."""
    cur = conn.execute(
        """
        INSERT OR IGNORE INTO mentions
          (post_id, mars_channel, mention_type, source_channel_id, source_username,
           source_title, source_subscribers, posted_at, found_at, post_link, alerted)
        VALUES
          (:post_id, :mars_channel, :mention_type, :source_channel_id, :source_username,
           :source_title, :source_subscribers, :posted_at, :found_at, :post_link, :alerted)
        """,
        {**row, "alerted": alerted},
    )
    conn.commit()
    return cur.rowcount == 1


def mark_alerted(conn: sqlite3.Connection, post_id: int) -> None:
    """Помечает строку как отправленную."""
    conn.execute("UPDATE mentions SET alerted=1 WHERE post_id=?", (post_id,))
    conn.commit()


def get_unalerted(conn: sqlite3.Connection) -> list[dict]:
    """Возвращает все строки с alerted=0 в виде списка dict (для re-send pass)."""
    conn.row_factory = sqlite3.Row
    cur = conn.execute(
        "SELECT * FROM mentions WHERE alerted=0 ORDER BY found_at"
    )
    return [dict(r) for r in cur.fetchall()]
```

- [ ] **Step 3.5: Прогнать тесты — должны пройти**

Run: `.venv/bin/pytest tests/test_mentions_db.py -v`
Expected: 8 PASS.

- [ ] **Step 3.6: Прогнать всё**

Run: `.venv/bin/pytest -v`
Expected: 90 PASS (82 + 8).

- [ ] **Step 3.7: Коммит**

```bash
git add src/mars_bot/mentions_db.py tests/test_mentions_db.py
git commit -m "feat(mentions_db): SQLite store with INSERT OR IGNORE + alerted flag"
```

---

## Task 4: Формат карточки (`alert.py`)

**Files:**
- Create: `src/mars_bot/alert.py`
- Create: `tests/test_alert.py`

- [ ] **Step 4.1: Failing test для `format_subscribers`**

Создать `tests/test_alert.py`:

```python
from mars_bot.alert import format_subscribers, render_card


def test_format_subscribers_small():
    assert format_subscribers(0) == "0"
    assert format_subscribers(45) == "45"
    assert format_subscribers(987) == "987"


def test_format_subscribers_thousands():
    assert format_subscribers(1000) == "1.0k"
    assert format_subscribers(3400) == "3.4k"
    assert format_subscribers(12345) == "12k"
    assert format_subscribers(513774) == "514k"


def test_format_subscribers_none():
    assert format_subscribers(None) == "—"
```

- [ ] **Step 4.2: Failing test для `render_card`**

Дописать:

```python
def test_render_card_basic():
    md = render_card(
        mars_channel="marsingru",
        source_username="mosptichka",
        source_title="Московская птичка",
        source_subscribers=513774,
        posted_at=1780992317,
        post_link="https://t.me/mosptichka/18151",
    )
    # Sanity assertions — конкретный формат проверяем golden-тестом ниже.
    assert "#mention" in md
    assert md.startswith("#mention")
    assert "@marsingru" in md
    assert "Московская птичка" in md
    assert "514k" in md
    assert "https://t.me/mosptichka/18151" in md
    assert "https://t.me/mosptichka" in md
    # Никаких угловых скобок вокруг URL.
    assert "(<" not in md
    assert ">)" not in md


def test_render_card_missing_subscribers():
    md = render_card(
        mars_channel="marsingru",
        source_username="x",
        source_title="X",
        source_subscribers=None,
        posted_at=1780000000,
        post_link="https://t.me/x/1",
    )
    assert "—" in md
```

- [ ] **Step 4.3: Прогнать — должны упасть**

Run: `.venv/bin/pytest tests/test_alert.py -v`
Expected: ImportError.

- [ ] **Step 4.4: Имплементить `alert.py`**

Создать `src/mars_bot/alert.py`:

```python
"""Сборка карточки упоминания (markdown → потом отрендерится через format.md_to_html)."""
from datetime import datetime, timezone
from typing import Optional


def format_subscribers(n: Optional[int]) -> str:
    """987 → '987'; 3400 → '3.4k'; 12345 → '12k'; 513774 → '514k'; None → '—'."""
    if n is None:
        return "—"
    if n < 1000:
        return str(n)
    thousands = n / 1000.0
    if thousands < 10:
        return f"{thousands:.1f}k"
    return f"{round(thousands)}k"


def render_card(
    mars_channel: str,
    source_username: str,
    source_title: str,
    source_subscribers: Optional[int],
    posted_at: int,
    post_link: str,
) -> str:
    """Возвращает markdown-карточку. Первая строка — #mention (без пробела).

    Дальше отдаётся в format.md_to_html для конвертации в HTML и отправляется
    через telegram.send_message с parse_mode='HTML'.
    """
    dt = datetime.fromtimestamp(posted_at, tz=timezone.utc)
    posted_str = dt.strftime("%d.%m.%Y %H:%M")
    source_link = f"https://t.me/{source_username}"
    return (
        f"#mention\n"
        f"\n"
        f"📢 **Упоминание @{mars_channel}**\n"
        f"\n"
        f"**Канал:** [{source_title}]({source_link}) · {format_subscribers(source_subscribers)} подписчиков\n"
        f"**Опубликовано:** {posted_str}\n"
        f"\n"
        f"[Открыть пост ↗]({post_link})\n"
    )
```

- [ ] **Step 4.5: Прогнать тесты — должны пройти**

Run: `.venv/bin/pytest tests/test_alert.py -v`
Expected: 5 PASS.

- [ ] **Step 4.6: Доп. тест — карточка корректно конвертится через md_to_html**

Дописать в `tests/test_alert.py`:

```python
from mars_bot.format import md_to_html


def test_render_card_passes_through_md_to_html():
    """Карточка должна успешно конвертиться в HTML — без сломанных тегов."""
    md = render_card(
        mars_channel="marsingru",
        source_username="x",
        source_title="X Channel",
        source_subscribers=100,
        posted_at=1780000000,
        post_link="https://t.me/x/1",
    )
    html = md_to_html(md)
    # Базовые проверки: ссылки стали <a>, bold стал <b>, hashtag сохранился.
    assert "<b>" in html
    assert "<a href=" in html
    assert "#mention" in html
    # Нет угловых скобок в href.
    assert 'href="<' not in html
    assert '>"' not in html.replace('parse_mode="HTML"', "")  # no broken anchors
```

- [ ] **Step 4.7: Прогнать — должен пройти**

Run: `.venv/bin/pytest tests/test_alert.py -v`
Expected: 6 PASS.

- [ ] **Step 4.8: Прогнать всё**

Run: `.venv/bin/pytest -v`
Expected: 96 PASS.

- [ ] **Step 4.9: Коммит**

```bash
git add src/mars_bot/alert.py tests/test_alert.py
git commit -m "feat(alert): mention card renderer with subscribers formatting"
```

---

## Task 5: Оркестратор `bot.py`

Самый большой таск. Реализуем по слоям: сначала core-функция `run_check`, потом CLI-обёртка с argparse.

**Files:**
- Create: `src/mars_bot/bot.py`
- Create: `tests/test_bot.py`

- [ ] **Step 5.1: Failing test — cold start не шлёт**

Создать `tests/test_bot.py`:

```python
import json
import sqlite3
from pathlib import Path
from unittest.mock import MagicMock

import pytest

from mars_bot.bot import run_check


def _setup_project(tmp_path: Path) -> Path:
    (tmp_path / ".env").write_text(
        "TELEGRAM_BOT_TOKEN=tg-token\nTGSTAT_TOKEN=tgstat-token\n"
    )
    (tmp_path / "chats.json").write_text(json.dumps({
        "test": 42,
        "team": -100,
        "_mentions": {"tracked": {"marsingru": "team", "natashhhh": "test"}},
    }))
    return tmp_path


SAMPLE_TGSTAT_RESPONSE = {
    "items": [
        {"mentionId": 1, "mentionType": "channel", "postId": 1001,
         "postLink": "https://t.me/a/1", "postDate": 1700000000, "channelId": 100},
        {"mentionId": 2, "mentionType": "channel", "postId": 1002,
         "postLink": "https://t.me/b/2", "postDate": 1700000001, "channelId": 200},
    ],
    "channels": [
        {"id": 100, "username": "@a", "title": "A", "participants_count": 1000,
         "link": "t.me/a"},
        {"id": 200, "username": "@b", "title": "B", "participants_count": 2000,
         "link": "t.me/b"},
    ],
}


def test_cold_start_fills_db_no_send(monkeypatch, tmp_path: Path):
    """При cold start (нет mentions.db) первый прогон молча заполняет, ничего не шлёт."""
    root = _setup_project(tmp_path)
    # Мок TGStat — отдаёт упоминания на оба наших канала.
    monkeypatch.setattr(
        "mars_bot.bot.tgstat.get_mentions",
        lambda **kw: SAMPLE_TGSTAT_RESPONSE,
    )
    # Мок telegram — фиксируем что НЕ вызывали.
    send_mock = MagicMock()
    monkeypatch.setattr("mars_bot.bot.telegram.send_message", send_mock)

    exit_code = run_check(root, dry_run=False)
    assert exit_code == 0
    assert send_mock.call_count == 0
    # Записи должны быть с alerted=1.
    db_path = root / "data" / "mentions.db"
    assert db_path.exists()
    with sqlite3.connect(db_path) as conn:
        rows = conn.execute("SELECT post_id, alerted FROM mentions").fetchall()
        # 2 канала × 2 поста = 4 строки, но post_id уникален (1001/1002 в обоих ответах)
        # — поэтому 2 строки.
        assert len(rows) == 2
        assert all(alerted == 1 for _, alerted in rows)
```

- [ ] **Step 5.2: Failing tests — normal pass, dedup, self-mention, dry-run, send-fail retry**

Дописать в `tests/test_bot.py`:

```python
def _pre_init_db(root: Path) -> None:
    """Создаёт пустую mentions.db чтобы run_check не считал это cold start."""
    from mars_bot.mentions_db import init
    init(root / "data" / "mentions.db")


def test_normal_pass_inserts_and_sends(monkeypatch, tmp_path: Path):
    root = _setup_project(tmp_path)
    _pre_init_db(root)
    monkeypatch.setattr(
        "mars_bot.bot.tgstat.get_mentions",
        lambda **kw: SAMPLE_TGSTAT_RESPONSE,
    )
    send_mock = MagicMock(return_value={"message_id": 1})
    monkeypatch.setattr("mars_bot.bot.telegram.send_message", send_mock)

    run_check(root, dry_run=False)
    # 2 уникальных поста × 1 (мы дедупим по post_id, оба канала вернули одно и то же)
    # = 2 отправки.
    assert send_mock.call_count == 2


def test_dedup_second_pass_sends_nothing(monkeypatch, tmp_path: Path):
    root = _setup_project(tmp_path)
    _pre_init_db(root)
    monkeypatch.setattr(
        "mars_bot.bot.tgstat.get_mentions",
        lambda **kw: SAMPLE_TGSTAT_RESPONSE,
    )
    send_mock = MagicMock(return_value={"message_id": 1})
    monkeypatch.setattr("mars_bot.bot.telegram.send_message", send_mock)

    run_check(root, dry_run=False)
    send_mock.reset_mock()
    run_check(root, dry_run=False)  # повторный прогон
    assert send_mock.call_count == 0


def test_self_mention_skipped(monkeypatch, tmp_path: Path):
    root = _setup_project(tmp_path)
    _pre_init_db(root)
    # TGStat вернул упоминание от самого @marsingru (наш канал).
    self_resp = {
        "items": [{"mentionId": 99, "mentionType": "channel", "postId": 9999,
                   "postLink": "https://t.me/marsingru/100",
                   "postDate": 1700000000, "channelId": 555}],
        "channels": [{"id": 555, "username": "@MARSINGRU", "title": "Mars",
                      "participants_count": 100, "link": "t.me/marsingru"}],
    }
    monkeypatch.setattr("mars_bot.bot.tgstat.get_mentions", lambda **kw: self_resp)
    send_mock = MagicMock()
    monkeypatch.setattr("mars_bot.bot.telegram.send_message", send_mock)

    run_check(root, dry_run=False)
    assert send_mock.call_count == 0
    # И в БД не должно быть.
    with sqlite3.connect(root / "data" / "mentions.db") as conn:
        rows = conn.execute("SELECT COUNT(*) FROM mentions").fetchall()
        assert rows == [(0,)]


def test_send_failure_keeps_alerted_zero(monkeypatch, tmp_path: Path):
    root = _setup_project(tmp_path)
    _pre_init_db(root)
    monkeypatch.setattr(
        "mars_bot.bot.tgstat.get_mentions",
        lambda **kw: SAMPLE_TGSTAT_RESPONSE,
    )
    from mars_bot.telegram import TelegramError
    monkeypatch.setattr(
        "mars_bot.bot.telegram.send_message",
        MagicMock(side_effect=TelegramError("boom")),
    )

    run_check(root, dry_run=False)
    with sqlite3.connect(root / "data" / "mentions.db") as conn:
        rows = conn.execute("SELECT alerted FROM mentions").fetchall()
        assert all(a == 0 for (a,) in rows)


def test_retry_pass_resends_failed(monkeypatch, tmp_path: Path):
    root = _setup_project(tmp_path)
    _pre_init_db(root)
    # Первый прогон: TGStat вернул, отправка падает.
    monkeypatch.setattr(
        "mars_bot.bot.tgstat.get_mentions",
        lambda **kw: SAMPLE_TGSTAT_RESPONSE,
    )
    from mars_bot.telegram import TelegramError
    monkeypatch.setattr(
        "mars_bot.bot.telegram.send_message",
        MagicMock(side_effect=TelegramError("boom")),
    )
    run_check(root, dry_run=False)

    # Второй прогон: TGStat вернул то же (дедуп пропускает), отправка работает —
    # должна добить alerted=0 через pre-pass.
    send_ok = MagicMock(return_value={"message_id": 1})
    monkeypatch.setattr("mars_bot.bot.telegram.send_message", send_ok)
    run_check(root, dry_run=False)

    assert send_ok.call_count == 2  # ровно две недосланные
    with sqlite3.connect(root / "data" / "mentions.db") as conn:
        rows = conn.execute("SELECT alerted FROM mentions").fetchall()
        assert all(a == 1 for (a,) in rows)


def test_dry_run_has_no_side_effects(monkeypatch, tmp_path: Path, capsys):
    root = _setup_project(tmp_path)
    # БД НЕ преинициализируем — cold start не должен сработать в dry-run.
    monkeypatch.setattr(
        "mars_bot.bot.tgstat.get_mentions",
        lambda **kw: SAMPLE_TGSTAT_RESPONSE,
    )
    send_mock = MagicMock()
    monkeypatch.setattr("mars_bot.bot.telegram.send_message", send_mock)

    run_check(root, dry_run=True)
    out = capsys.readouterr().out
    assert "#mention" in out  # карточка отрендерилась
    assert send_mock.call_count == 0
    assert not (root / "data" / "mentions.db").exists()
```

- [ ] **Step 5.3: Прогнать — должны упасть**

Run: `.venv/bin/pytest tests/test_bot.py -v`
Expected: ImportError на `mars_bot.bot`.

- [ ] **Step 5.4: Имплементить `bot.py`**

Создать `src/mars_bot/bot.py`:

```python
"""mars-bot mentions watcher — one-shot прогон.

Workflow:
  1. Загрузить .env + chats.json (включая _mentions блок).
  2. Если БД не существует — cold start: заполнить без отправки, всё с alerted=1.
  3. Pre-pass: добить alerted=0 (остатки прошлых сбоев Bot API).
  4. Main pass: для каждого tracked-канала вызвать TGStat, новые → insert + send + mark.
  5. Лог в stdout, exit 0.

Dry-run: рендерит карточки в stdout, БД не пишет, TG не вызывает.
"""
import argparse
import os
import sqlite3
import sys
import time
from pathlib import Path
from typing import Optional

from dotenv import load_dotenv

from mars_bot import alert, mentions_db, tgstat, telegram
from mars_bot.config import load_config, load_mentions_config
from mars_bot.format import md_to_html


REQUEST_DELAY = 0.3  # пауза между TGStat-вызовами


class MentionsRunError(Exception):
    """Тонкий wrapper для exit-кодов на верхнем уровне."""


def _load_tgstat_token(project_root: Path) -> str:
    load_dotenv(project_root / ".env", override=True)
    tok = os.environ.get("TGSTAT_TOKEN", "").strip()
    if not tok:
        raise MentionsRunError("TGSTAT_TOKEN missing or empty in .env")
    return tok


def _build_row(item: dict, channels_by_id: dict, mars_channel: str, found_at: int) -> dict:
    """Собрать row для БД из items[] + channels[] (через channelId mapping)."""
    ch = channels_by_id.get(item.get("channelId"), {})
    norm_username = tgstat.normalize_username(ch.get("username"))
    return {
        "post_id": item["postId"],
        "mars_channel": mars_channel,
        "mention_type": item.get("mentionType", "channel"),
        "source_channel_id": item.get("channelId"),
        "source_username": norm_username,
        "source_title": ch.get("title"),
        "source_subscribers": ch.get("participants_count"),
        "posted_at": item.get("postDate", found_at),
        "found_at": found_at,
        "post_link": item.get("postLink", ""),
    }


def _send_alert(token: str, chat_id: int, row: dict) -> None:
    card_md = alert.render_card(
        mars_channel=row["mars_channel"],
        source_username=row["source_username"] or "",
        source_title=row["source_title"] or "(без названия)",
        source_subscribers=row["source_subscribers"],
        posted_at=row["posted_at"],
        post_link=row["post_link"],
    )
    html = md_to_html(card_md)
    telegram.send_message(token, chat_id, html)


def run_check(project_root: Path, dry_run: bool = False) -> int:
    """Один прогон. Возвращает exit code."""
    cfg = load_config(project_root)
    mentions_cfg = load_mentions_config(project_root)
    if mentions_cfg is None:
        print("[mentions] no _mentions block in chats.json — nothing to do", file=sys.stderr)
        return 0

    tgstat_token = _load_tgstat_token(project_root)
    db_path = project_root / "data" / "mentions.db"
    tracked = list(mentions_cfg.tracked.keys())
    cold = mentions_db.is_cold_start(db_path)

    # В dry-run БД не трогаем — рендерим карточки в stdout.
    if dry_run:
        return _run_dry(tgstat_token, mentions_cfg, tracked)

    mentions_db.init(db_path)
    found_at = int(time.time())

    with sqlite3.connect(db_path) as conn:
        # PRE-PASS: добить недосланные.
        if not cold:
            for row in mentions_db.get_unalerted(conn):
                dest_alias = mentions_cfg.tracked.get(row["mars_channel"])
                if dest_alias is None:
                    print(
                        f"[mentions] orphan row mars_channel={row['mars_channel']!r}, skip",
                        file=sys.stderr,
                    )
                    continue
                chat_id = cfg.chats[dest_alias]
                try:
                    _send_alert(cfg.bot_token, chat_id, row)
                    mentions_db.mark_alerted(conn, row["post_id"])
                except telegram.TelegramError as e:
                    print(f"[mentions] retry send failed for {row['post_id']}: {e}",
                          file=sys.stderr)

        # MAIN PASS.
        new_count = 0
        sent_count = 0
        failed_count = 0
        for username in tracked:
            try:
                resp = tgstat.get_mentions(
                    token=tgstat_token,
                    channel_id=f"@{username}",
                    extended=True,
                    limit=50,
                )
            except tgstat.TGStatError as e:
                print(f"[mentions] TGStat error for @{username}: {e}", file=sys.stderr)
                continue

            channels_by_id = {c["id"]: c for c in resp.get("channels", [])}
            for item in resp.get("items", []):
                ch = channels_by_id.get(item.get("channelId"), {})
                if tgstat.is_self_mention(ch.get("username"), tracked):
                    continue
                row = _build_row(item, channels_by_id, username, found_at)
                cold_flag = 1 if cold else 0
                inserted = mentions_db.insert_or_ignore(conn, row, alerted=cold_flag)
                if not inserted:
                    continue
                new_count += 1
                if cold:
                    continue  # cold start не шлёт
                dest_alias = mentions_cfg.tracked[username]
                chat_id = cfg.chats[dest_alias]
                try:
                    _send_alert(cfg.bot_token, chat_id, row)
                    mentions_db.mark_alerted(conn, row["post_id"])
                    sent_count += 1
                except telegram.TelegramError as e:
                    failed_count += 1
                    print(f"[mentions] send failed for {row['post_id']}: {e}",
                          file=sys.stderr)
            time.sleep(REQUEST_DELAY)

    mode = "cold-start (no send)" if cold else "normal"
    print(f"[mentions] mode={mode} new={new_count} sent={sent_count} failed={failed_count}")
    return 0


def _run_dry(tgstat_token: str, mentions_cfg, tracked: list) -> int:
    """Печатает карточки в stdout. БД и TG не трогаем."""
    found_at = int(time.time())
    for username in tracked:
        try:
            resp = tgstat.get_mentions(
                token=tgstat_token,
                channel_id=f"@{username}",
                extended=True,
                limit=50,
            )
        except tgstat.TGStatError as e:
            print(f"[dry-run] TGStat error for @{username}: {e}", file=sys.stderr)
            continue
        channels_by_id = {c["id"]: c for c in resp.get("channels", [])}
        for item in resp.get("items", []):
            ch = channels_by_id.get(item.get("channelId"), {})
            if tgstat.is_self_mention(ch.get("username"), tracked):
                continue
            row = _build_row(item, channels_by_id, username, found_at)
            card = alert.render_card(
                mars_channel=row["mars_channel"],
                source_username=row["source_username"] or "",
                source_title=row["source_title"] or "(без названия)",
                source_subscribers=row["source_subscribers"],
                posted_at=row["posted_at"],
                post_link=row["post_link"],
            )
            print("=" * 60)
            print(card)
        time.sleep(REQUEST_DELAY)
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(prog="mars-bot-mentions")
    sub = parser.add_subparsers(dest="cmd", required=True)
    p_check = sub.add_parser("check", help="One pass over tracked channels")
    p_check.add_argument("--dry-run", action="store_true",
                         help="Print cards to stdout, no DB write, no TG send")
    args = parser.parse_args()

    project_root = Path(__file__).resolve().parent.parent.parent
    try:
        if args.cmd == "check":
            return run_check(project_root, dry_run=args.dry_run)
    except MentionsRunError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    return 1
```

- [ ] **Step 5.5: Прогнать тесты — должны пройти**

Run: `.venv/bin/pytest tests/test_bot.py -v`
Expected: 6 PASS.

Если cold-start тест падает на «4 строки» вместо 2 — это потому что `SAMPLE_TGSTAT_RESPONSE` возвращается ОБОИМ запросам (`marsingru` и `natashhhh`); `INSERT OR IGNORE` на тот же `post_id` сделает второй вызов no-op, итого 2 строки. Если тест по-другому считает — поправить под факт.

- [ ] **Step 5.6: Прогнать всё**

Run: `.venv/bin/pytest -v`
Expected: 102 PASS.

- [ ] **Step 5.7: Доп. тест — multi-channel collision (порядок tracked = победитель)**

Дописать в `tests/test_bot.py`:

```python
def test_multi_channel_collision_first_wins(monkeypatch, tmp_path: Path):
    """Пост, упомянувший marsingru И natashhhh. Победитель = первый в tracked."""
    root = _setup_project(tmp_path)  # порядок: marsingru → team, natashhhh → test
    _pre_init_db(root)

    # Один и тот же пост вернётся в обоих запросах.
    shared = {
        "items": [{"mentionId": 1, "mentionType": "channel", "postId": 7777,
                   "postLink": "https://t.me/blogger/1", "postDate": 1700000000,
                   "channelId": 50}],
        "channels": [{"id": 50, "username": "@blogger", "title": "Blogger",
                      "participants_count": 500, "link": "t.me/blogger"}],
    }
    monkeypatch.setattr("mars_bot.bot.tgstat.get_mentions", lambda **kw: shared)
    send_mock = MagicMock(return_value={"message_id": 1})
    monkeypatch.setattr("mars_bot.bot.telegram.send_message", send_mock)

    run_check(root, dry_run=False)
    # Один уникальный post_id → одна отправка, в destination первого в tracked.
    assert send_mock.call_count == 1
    chat_id_called = send_mock.call_args[0][1]
    assert chat_id_called == -100  # team (destination для marsingru)

    # В БД одна строка с mars_channel='marsingru'.
    with sqlite3.connect(root / "data" / "mentions.db") as conn:
        row = conn.execute("SELECT mars_channel FROM mentions").fetchone()
        assert row == ("marsingru",)
```

- [ ] **Step 5.8: Прогнать — должен пройти**

Run: `.venv/bin/pytest tests/test_bot.py::test_multi_channel_collision_first_wins -v`
Expected: PASS.

- [ ] **Step 5.9: Прогнать всё**

Run: `.venv/bin/pytest -v`
Expected: 103 PASS.

- [ ] **Step 5.10: Коммит**

```bash
git add src/mars_bot/bot.py tests/test_bot.py
git commit -m "feat(bot): mentions watcher with cold-start, dedup, dry-run, retry"
```

---

## Task 6: Wrapper-скрипт

**Files:**
- Create: `scripts/mars-bot-mentions`

- [ ] **Step 6.1: Создать обёртку по образцу `scripts/mars-bot`**

Создать `scripts/mars-bot-mentions`:

```python
#!/usr/bin/env python3
"""mars-bot-mentions wrapper, bypasses macOS UF_HIDDEN on .venv/.pth files.

See scripts/mars-bot for the full explanation.

Invoke via:
    .venv/bin/python scripts/mars-bot-mentions check
    .venv/bin/python scripts/mars-bot-mentions check --dry-run
"""
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))

from mars_bot.bot import main

if __name__ == "__main__":
    raise SystemExit(main())
```

- [ ] **Step 6.2: Сделать исполняемым**

Run: `chmod +x scripts/mars-bot-mentions`

- [ ] **Step 6.3: Импорт-смок**

Run: `.venv/bin/python scripts/mars-bot-mentions --help`
Expected: вывод argparse usage с подкомандой `check`.

- [ ] **Step 6.4: Коммит**

```bash
git add scripts/mars-bot-mentions
git commit -m "feat(scripts): mars-bot-mentions wrapper (same pattern as scripts/mars-bot)"
```

---

## Task 7: systemd unit-файлы + README

**Files:**
- Create: `docs/systemd/mars-bot-mentions.service`
- Create: `docs/systemd/mars-bot-mentions.timer`
- Modify: `README.md`

- [ ] **Step 7.1: Создать `docs/systemd/mars-bot-mentions.service`**

```ini
[Unit]
Description=mars-bot mentions watcher (one-shot)

[Service]
Type=oneshot
WorkingDirectory=/home/pi/mars-bot
ExecStart=/home/pi/mars-bot/.venv/bin/python scripts/mars-bot-mentions check
User=pi
```

- [ ] **Step 7.2: Создать `docs/systemd/mars-bot-mentions.timer`**

```ini
[Unit]
Description=Run mars-bot mentions watcher every 4 hours

[Timer]
OnBootSec=5min
OnUnitActiveSec=4h
RandomizedDelaySec=5min
Persistent=true

[Install]
WantedBy=timers.target
```

- [ ] **Step 7.3: Дописать секцию в README**

Открыть `README.md`. После секции `## Files` добавить:

```markdown
## Mentions watcher

Periodically polls TGStat `channels/mentions` for mentions of Mars channels and sends an alert card to the configured chat alias.

### Configuration

Add to `.env`:
```
TGSTAT_TOKEN=<your token from api.tgstat.ru>
```

Add `_mentions` block to `chats.json`:
```json
{
  "test": 822794,
  "backoffice": -1001227818099,
  ...,
  "_mentions": {
    "tracked": {
      "marsingru": "backoffice",
      "choooooooir": "backoffice",
      "tvorcheskiye_lyudi": "backoffice",
      "natashhhh": "test"
    }
  }
}
```

Each `tracked` key is a channel username (without `@`); each value is an alias from `chats.json` where alerts go.

### Local smoke

```bash
.venv/bin/python scripts/mars-bot-mentions check --dry-run
```

Prints rendered cards to stdout. No DB write, no TG send.

### Deploy on Pi

systemd unit templates: `docs/systemd/mars-bot-mentions.service`, `docs/systemd/mars-bot-mentions.timer`.

```bash
sudo cp docs/systemd/mars-bot-mentions.* /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now mars-bot-mentions.timer
sudo systemctl status mars-bot-mentions.timer
journalctl -u mars-bot-mentions -f
```

Timer fires 5 min after boot, then every 4 hours.

### Notes

- **Cold start**: first run with no `data/mentions.db` silently absorbs all current mentions without sending alerts. From the second run onwards — normal alerts on new ones.
- **Multi-channel collision**: if one post mentions multiple of our channels, only one alert goes to the destination of the first match in `tracked` order. Documented edge case, switch to composite PK if it becomes annoying.
- **Retries**: failed sends keep `alerted=0` in DB; next run re-tries via pre-pass.
```

- [ ] **Step 7.4: Прогнать ВСЕ тесты в финале**

Run: `.venv/bin/pytest -v`
Expected: 103 PASS.

- [ ] **Step 7.5: Коммит**

```bash
git add docs/systemd/ README.md
git commit -m "docs(mentions): systemd units + README usage section"
```

---

## Task 8: Локальный smoke на реальном API

Не автоматизируется тестами. Делается руками после Task 7.

- [ ] **Step 8.1: Положить токен и `_mentions` блок локально**

Открыть `.env` — добавить строку:
```
TGSTAT_TOKEN=<скопировать из projects/vibe-coding/tgstat-puller/.env>
```

Открыть `chats.json` — добавить блок `_mentions` (можно ограничиться одним каналом и `test`-destination для безопасности):

```json
"_mentions": {
  "tracked": {
    "marsingru": "test"
  }
}
```

- [ ] **Step 8.2: Dry-run**

Run: `.venv/bin/python scripts/mars-bot-mentions check --dry-run`
Expected: stdout содержит карточки с реальными упоминаниями @marsingru. Файла `data/mentions.db` нет.

- [ ] **Step 8.3: Cold start — реальный прогон**

Run: `.venv/bin/python scripts/mars-bot-mentions check`
Expected: лог `mode=cold-start (no send) new=N sent=0 failed=0`. Файл `data/mentions.db` создан. В TG ничего не пришло.

- [ ] **Step 8.4: Дождаться нового упоминания / форсировать ручной тест**

Способ 1 (медленно): дождаться, пока @marsingru упомянут где-то новенько → запустить `check` снова → должна прилететь карточка в `test`-чат.

Способ 2 (быстро): очистить БД (`rm data/mentions.db`) и запустить `check` дважды подряд:
- Первый запуск = cold start (молча заполнит)
- Перед вторым: `sqlite3 data/mentions.db "UPDATE mentions SET alerted=0 LIMIT 1"`
- Второй `check` сработает как retry-pass и отправит ровно одну карточку

- [ ] **Step 8.5: Проверить карточку в чате**

Смотрим визуально: жирные тексты, кликабельные ссылки, нет `<>` в href, `#mention` распознан как hashtag.

- [ ] **Step 8.6: Откатить ограничения, поставить полный конфиг**

После успешного smoke — `_mentions.tracked` расширить до полного списка (`marsingru` → `backoffice`, и т.д. по дизайну). Никаких code-коммитов на этом шаге.

- [ ] **Step 8.7: Финальный коммит — пометить deployed**

Никакого кода, но проставим тег:

```bash
git tag mentions-mvp-deployed-local
git log --oneline -10
```

---

## Что НЕ в этом плане

- Деплой на Pi (отдельная задача — копирование репо, установка venv, systemd). Из плана: артефакты unit-файлов уже готовы; команды деплоя в README.
- Реальная оплата тарифа TGStat — Наташа сделала вручную до начала имплементации.
- Аналитика, сентимент, composite PK, текст поста в карточке — out of scope (см. спеку).
