---
tags:
  - type/plan
  - topic/vibe-coding
date: 2026-06-05
---

# mars-bot MVP Implementation Plan

> **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:** Собрать CLI-утилиту `mars-bot send`, которая отправляет произвольный markdown-текст в один из преднастроенных Mars-чатов в Telegram через Bot API, плюс slash-команду `/send-summary` в Claude для саммари митингов.

**Architecture:** Python CLI, локально на Mac. Без долгоиграющего процесса. Markdown → HTML, дробление с двумя проходами (с запасом), file-backed rate-limit через `sent.log`, whitelist алиасов из `chats.json`. Slash-команда отвечает за: чтение транскрипта, генерацию саммари, диалоговый review-gate, запись текста во временный файл, вызов CLI, удаление temp-файла в finally.

**Tech Stack:** Python 3.11, `requests` (HTTP к Bot API), `python-dotenv` (.env), `argparse` (stdlib, CLI), `pytest` + `pytest-mock` (тесты). Никаких сторонних markdown-библиотек — конвертер пишем сами (TG-подмножество HTML).

**Reference spec:** `projects/vibe-coding/mars-bot/docs/{vibe-coding} {plan} mars-bot mvp дизайн – 2026-06-05.md`

---

## Task 1: Скелет репо и git init

**Files:**
- Create: `projects/vibe-coding/mars-bot/.gitignore`
- Create: `projects/vibe-coding/mars-bot/.env.example`
- Create: `projects/vibe-coding/mars-bot/chats.json.example`
- Create: `projects/vibe-coding/mars-bot/pyproject.toml`
- Create: `projects/vibe-coding/mars-bot/README.md` (заглушка, расширим в Task 11)
- Create: `projects/vibe-coding/mars-bot/src/mars_bot/__init__.py` (пустой)
- Create: `projects/vibe-coding/mars-bot/tests/__init__.py` (пустой)

- [ ] **Step 1: Создать структуру каталогов**

```bash
cd /Users/nataliadudina/Desktop/ObsidianVault/projects/vibe-coding/mars-bot
mkdir -p src/mars_bot scripts tests data
touch src/mars_bot/__init__.py tests/__init__.py
```

- [ ] **Step 2: Создать `.gitignore`**

```
.env
chats.json
data/
.venv/
__pycache__/
*.pyc
.pytest_cache/
*.egg-info/
```

- [ ] **Step 3: Создать `.env.example`**

```
TELEGRAM_BOT_TOKEN=put-your-bot-token-here
```

- [ ] **Step 4: Создать `chats.json.example`**

```json
{
  "team": -100123456789,
  "marketing": -100987654321,
  "ops": -100111222333,
  "test": 12345678
}
```

- [ ] **Step 5: Создать `pyproject.toml`**

```toml
[project]
name = "mars-bot"
version = "0.1.0"
description = "Mars team Telegram bot — meeting summaries delivery"
requires-python = ">=3.11"
dependencies = [
    "requests>=2.31",
    "python-dotenv>=1.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.4",
    "pytest-mock>=3.12",
]

[project.scripts]
mars-bot = "mars_bot.cli:main"

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]
```

- [ ] **Step 6: Создать заглушку `README.md`**

```markdown
# mars-bot

CLI for sending markdown text to Mars Telegram chats. See `docs/` for full design and implementation plan.

Full setup, troubleshooting and emergency-stop instructions land here in Task 11.
```

- [ ] **Step 7: Git init и первый коммит**

```bash
cd /Users/nataliadudina/Desktop/ObsidianVault/projects/vibe-coding/mars-bot
git init
git add .gitignore .env.example chats.json.example pyproject.toml README.md src/ tests/ docs/
git commit -m "chore: scaffold mars-bot project"
```

- [ ] **Step 8: Создать venv и поставить зависимости**

```bash
cd /Users/nataliadudina/Desktop/ObsidianVault/projects/vibe-coding/mars-bot
python3.11 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest --version
```

Expected: pytest версия печатается без ошибок (тесты ещё нет — pytest just exits clean).

---

## Task 2: `config.py` — загрузка .env и chats.json

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

- [ ] **Step 1: Написать падающий тест на загрузку валидного конфига**

`tests/test_config.py`:

```python
import json
import pytest
from pathlib import Path
from mars_bot.config import load_config, ConfigError


def test_load_config_happy_path(tmp_path: Path):
    (tmp_path / ".env").write_text("TELEGRAM_BOT_TOKEN=test-token\n")
    chats = {"team": -100123, "test": 42}
    (tmp_path / "chats.json").write_text(json.dumps(chats))

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

- [ ] **Step 2: Run test — должен упасть на импорте**

Run: `.venv/bin/pytest tests/test_config.py -v`
Expected: FAIL (`ModuleNotFoundError: mars_bot.config`)

- [ ] **Step 3: Минимальная реализация `config.py`**

`src/mars_bot/config.py`:

```python
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Dict

from dotenv import load_dotenv


class ConfigError(Exception):
    pass


@dataclass(frozen=True)
class Config:
    bot_token: str
    chats: Dict[str, int]


def load_config(project_root: Path) -> Config:
    env_path = project_root / ".env"
    if not env_path.exists():
        raise ConfigError(f".env not found at {env_path}")
    load_dotenv(env_path, override=True)

    token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
    if not token:
        raise ConfigError("TELEGRAM_BOT_TOKEN missing or empty in .env")

    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}")

    if not isinstance(chats_raw, dict):
        raise ConfigError("chats.json must be an object {alias: chat_id}")
    if not chats_raw:
        raise ConfigError("chats.json is empty — at least one alias required")
    for alias, chat_id in chats_raw.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=chats_raw)
```

- [ ] **Step 4: Run test — должен пройти**

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

- [ ] **Step 5: Добавить тесты на негативные кейсы**

`tests/test_config.py` (добавить функции):

```python
def test_load_config_missing_env(tmp_path: Path):
    with pytest.raises(ConfigError, match=".env not found"):
        load_config(tmp_path)


def test_load_config_missing_token(tmp_path: Path):
    (tmp_path / ".env").write_text("OTHER=x\n")
    (tmp_path / "chats.json").write_text('{"team": 1}')
    with pytest.raises(ConfigError, match="TELEGRAM_BOT_TOKEN missing"):
        load_config(tmp_path)


def test_load_config_invalid_json(tmp_path: Path):
    (tmp_path / ".env").write_text("TELEGRAM_BOT_TOKEN=t\n")
    (tmp_path / "chats.json").write_text("not json")
    with pytest.raises(ConfigError, match="not valid JSON"):
        load_config(tmp_path)


def test_load_config_non_int_chat_id(tmp_path: Path):
    (tmp_path / ".env").write_text("TELEGRAM_BOT_TOKEN=t\n")
    (tmp_path / "chats.json").write_text('{"team": "not-int"}')
    with pytest.raises(ConfigError, match="must be int"):
        load_config(tmp_path)


def test_load_config_empty_chats(tmp_path: Path):
    (tmp_path / ".env").write_text("TELEGRAM_BOT_TOKEN=t\n")
    (tmp_path / "chats.json").write_text("{}")
    with pytest.raises(ConfigError, match="at least one alias"):
        load_config(tmp_path)
```

- [ ] **Step 6: Run all config tests**

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

- [ ] **Step 7: Commit**

```bash
git add src/mars_bot/config.py tests/test_config.py
git commit -m "feat: config loader for .env and chats.json"
```

---

## Task 3: `format.py` — markdown → HTML конвертация

**Files:**
- Create: `src/mars_bot/format.py`
- Create: `tests/test_format.py`

Стратегия: токенизация через placeholder'ы. Сначала находим markdown-конструкции, заменяем на уникальные placeholder'ы, эскейпим всё остальное (HTML-спецсимволы в обычном тексте), затем восстанавливаем placeholder'ы → HTML-теги. Внутри тегов контент тоже эскейпится при подстановке.

- [ ] **Step 1: Тесты на эскейп HTML-спецсимволов в обычном тексте**

`tests/test_format.py`:

```python
from mars_bot.format import md_to_html


def test_escape_amp():
    assert md_to_html("a & b") == "a &amp; b"


def test_escape_lt_gt():
    assert md_to_html("a < b > c") == "a &lt; b &gt; c"


def test_plain_text_unchanged():
    assert md_to_html("просто текст") == "просто текст"
```

- [ ] **Step 2: Run — FAIL**

Run: `.venv/bin/pytest tests/test_format.py -v`
Expected: FAIL (ModuleNotFoundError)

- [ ] **Step 3: Минимальная реализация — пока только escape**

`src/mars_bot/format.py`:

```python
import html
import re


def md_to_html(md: str) -> str:
    return html.escape(md, quote=False)
```

- [ ] **Step 4: Run — PASS**

Run: `.venv/bin/pytest tests/test_format.py -v`
Expected: 3 PASS

- [ ] **Step 5: Тесты на bold, italic, headings, links, lists**

Добавить в `tests/test_format.py`:

```python
def test_bold():
    assert md_to_html("**жирно**") == "<b>жирно</b>"


def test_italic():
    assert md_to_html("*курсив*") == "<i>курсив</i>"


def test_bold_and_italic_together():
    assert md_to_html("**bold** и *italic*") == "<b>bold</b> и <i>italic</i>"


def test_heading_h1():
    assert md_to_html("# Заголовок") == "<b>Заголовок</b>"


def test_heading_h2():
    assert md_to_html("## Заголовок") == "<b>Заголовок</b>"


def test_heading_h3():
    assert md_to_html("### Заголовок") == "<b>Заголовок</b>"


def test_link():
    assert md_to_html("[ссылка](https://example.com)") == '<a href="https://example.com">ссылка</a>'


def test_link_with_query_params():
    assert md_to_html("[x](https://a.com/?a=1&b=2)") == '<a href="https://a.com/?a=1&amp;b=2">x</a>'


def test_link_text_escaped():
    assert md_to_html("[<x>](https://a.com)") == '<a href="https://a.com">&lt;x&gt;</a>'


def test_dash_list_becomes_bullet():
    # NB: спека первой версии говорила «оставляем дефис как есть». Здесь сознательно
    # отходим — `•` читабельнее в TG, дефис в начале строки легко спутать с минусом.
    # Дизайн обновлён под это поведение.
    assert md_to_html("- first\n- second") == "• first\n• second"


def test_asterisk_list_becomes_bullet():
    assert md_to_html("* first\n* second") == "• first\n• second"


def test_escape_inside_bold():
    assert md_to_html("**a & b**") == "<b>a &amp; b</b>"


def test_asterisk_inside_word_not_italic():
    assert md_to_html("a*b*c") == "a*b*c"


def test_unclosed_bold_left_as_text():
    assert md_to_html("text with ** unclosed") == "text with ** unclosed"


def test_combined():
    md = "## Главные боли\n\n- **Никто** не понимал, кто [за что](https://x.com) отвечает."
    expected = (
        "<b>Главные боли</b>\n"
        "\n"
        "• <b>Никто</b> не понимал, кто <a href=\"https://x.com\">за что</a> отвечает."
    )
    assert md_to_html(md) == expected
```

- [ ] **Step 6: Run — все новые упадут**

Run: `.venv/bin/pytest tests/test_format.py -v`
Expected: остальные FAIL

- [ ] **Step 7: Полная реализация `md_to_html`**

Заменить `src/mars_bot/format.py` целиком:

```python
import html
import re
from typing import Dict


_PLACEHOLDER_PREFIX = "\x00TAG"
_PLACEHOLDER_SUFFIX = "\x00"


def _stash(placeholders: Dict[str, str], rendered_html: str) -> str:
    key = f"{_PLACEHOLDER_PREFIX}{len(placeholders)}{_PLACEHOLDER_SUFFIX}"
    placeholders[key] = rendered_html
    return key


def md_to_html(md: str) -> str:
    """Convert a small markdown subset to Telegram-compatible HTML.

    Recognised: # / ## / ### headings → <b>; **bold**; *italic* (whole token only);
    [text](url) links with href escaped; lines starting with '- ' or '* ' → '• '.
    Everything else is plain text with HTML special chars escaped.
    """
    placeholders: Dict[str, str] = {}
    text = md

    # 1. Headings (line-level, must precede bold/italic so '##' is not consumed)
    def repl_heading(m: re.Match) -> str:
        content = m.group(2)
        return _stash(placeholders, f"<b>{html.escape(content, quote=False)}</b>")

    text = re.sub(
        r"^(#{1,3})[ \t]+(.+?)[ \t]*$",
        repl_heading,
        text,
        flags=re.MULTILINE,
    )

    # 2. Links — before bold/italic to avoid '*' inside URL or text confusing italic
    def repl_link(m: re.Match) -> str:
        link_text = m.group(1)
        href = m.group(2)
        return _stash(
            placeholders,
            f'<a href="{html.escape(href, quote=True)}">{html.escape(link_text, quote=False)}</a>',
        )

    text = re.sub(r"\[([^\]\n]+?)\]\(([^)\n]+?)\)", repl_link, text)

    # 3. Bold (** first — must precede italic so '**x**' is not eaten by *...*)
    def repl_bold(m: re.Match) -> str:
        return _stash(placeholders, f"<b>{html.escape(m.group(1), quote=False)}</b>")

    text = re.sub(r"\*\*([^*\n]+?)\*\*", repl_bold, text)

    # 4. Italic — only when '*' has whitespace/start before and whitespace/end/punct after,
    #    so 'a*b*c' inside a word is left alone.
    def repl_italic(m: re.Match) -> str:
        return _stash(placeholders, f"<i>{html.escape(m.group(1), quote=False)}</i>")

    text = re.sub(
        r"(?<![\w*])\*([^*\n]+?)\*(?![\w*])",
        repl_italic,
        text,
    )

    # 5. List bullets: '- item' or '* item' at line start → '• item'
    text = re.sub(r"(?m)^[ \t]*[-*][ \t]+", "• ", text)

    # 6. Escape everything that remains (the literal text body)
    text = html.escape(text, quote=False)

    # 7. Restore placeholders (their content is already escaped)
    for key, rendered in placeholders.items():
        text = text.replace(key, rendered)

    return text
```

- [ ] **Step 8: Run all format tests**

Run: `.venv/bin/pytest tests/test_format.py -v`
Expected: all PASS

Если какой-то кейс падает — поправить regex точечно, добавить тест-фикс. Не переписывать всю логику.

- [ ] **Step 9: Commit**

```bash
git add src/mars_bot/format.py tests/test_format.py
git commit -m "feat: markdown → Telegram HTML converter"
```

---

## Task 4: `format.py` — дробление длинных сообщений

**Files:**
- Modify: `src/mars_bot/format.py` (добавить функции)
- Modify: `tests/test_format.py` (добавить тесты)

- [ ] **Step 1: Тесты на короткий текст (один чанк)**

Добавить в `tests/test_format.py`:

```python
from mars_bot.format import md_to_html_chunks, MAX_HTML_LENGTH, TARGET_MD_LENGTH


def test_chunks_short_text_single_chunk():
    chunks = md_to_html_chunks("короткое сообщение")
    assert chunks == ["короткое сообщение"]


def test_chunks_short_with_formatting():
    chunks = md_to_html_chunks("**bold** text")
    assert chunks == ["<b>bold</b> text"]
```

- [ ] **Step 2: Run — FAIL (ImportError)**

Run: `.venv/bin/pytest tests/test_format.py::test_chunks_short_text_single_chunk -v`
Expected: FAIL

- [ ] **Step 3: Минимальная реализация (один чанк, без дробления)**

Добавить в `src/mars_bot/format.py`:

```python
from typing import List

MAX_HTML_LENGTH = 4000  # запас под лимит TG 4096
TARGET_MD_LENGTH = 3500  # markdown с запасом на расширение от escape/tags


def md_to_html_chunks(md: str) -> List[str]:
    """Convert markdown to one or more HTML chunks, each ≤ MAX_HTML_LENGTH.

    Strategy lands in Step 10 — for now, single-chunk passthrough so the
    short-text tests can pass.
    """
    return [md_to_html(md)]


def _split_markdown(md: str, target_md_length: int = TARGET_MD_LENGTH) -> List[str]:
    if len(md) <= target_md_length:
        return [md]
    # Real splitting lands in Step 7
    return [md]
```

- [ ] **Step 4: Run short-text tests — PASS**

Run: `.venv/bin/pytest tests/test_format.py::test_chunks_short_text_single_chunk tests/test_format.py::test_chunks_short_with_formatting -v`
Expected: 2 PASS

- [ ] **Step 5: Тесты на длинный markdown — дробление по абзацам**

Добавить:

```python
def test_chunks_split_by_paragraphs():
    para = "a" * 1500
    md = "\n\n".join([para, para, para])  # ~4500 chars markdown
    chunks = md_to_html_chunks(md)
    assert len(chunks) >= 2
    for chunk in chunks:
        assert len(chunk) <= MAX_HTML_LENGTH


def test_chunks_preserve_paragraphs_on_boundary():
    # Каждый абзац должен оказаться целиком в одном чанке (никаких '\n\n' внутри одного абзаца поделено)
    p1 = "abc " * 200  # ~800
    p2 = "def " * 200
    p3 = "ghi " * 200
    p4 = "jkl " * 200
    p5 = "mno " * 200
    md = "\n\n".join([p1, p2, p3, p4, p5])  # ~4000 markdown, > TARGET_MD_LENGTH=3500
    chunks = md_to_html_chunks(md)
    # Каждый кусок не содержит частичных абзацев — проверяем что abc, def итд не разорваны
    joined = "".join(chunks)
    for marker in ["abc", "def", "ghi", "jkl", "mno"]:
        assert marker in joined
```

- [ ] **Step 6: Run — FAIL**

Run: `.venv/bin/pytest tests/test_format.py::test_chunks_split_by_paragraphs tests/test_format.py::test_chunks_preserve_paragraphs_on_boundary -v`
Expected: FAIL (один чанк, > MAX_HTML_LENGTH)

- [ ] **Step 7: Реализовать `_split_markdown`**

Заменить заглушку в `src/mars_bot/format.py`:

```python
def _split_markdown(md: str, target_md_length: int = TARGET_MD_LENGTH) -> List[str]:
    if len(md) <= target_md_length:
        return [md]
    paragraphs = md.split("\n\n")
    chunks: List[str] = []
    current = ""
    for para in paragraphs:
        candidate = para if not current else f"{current}\n\n{para}"
        if len(candidate) <= target_md_length:
            current = candidate
        else:
            if current:
                chunks.append(current)
            if len(para) <= target_md_length:
                current = para
            else:
                # Один абзац длиннее цели — режем по предложениям
                chunks.extend(_split_paragraph_by_sentences(para, target_md_length))
                current = ""
    if current:
        chunks.append(current)
    return chunks


def _split_paragraph_by_sentences(text: str, max_len: int) -> List[str]:
    sentences = re.split(r"(?<=[.!?])\s+", text)
    chunks: List[str] = []
    current = ""
    for s in sentences:
        candidate = s if not current else f"{current} {s}"
        if len(candidate) <= max_len:
            current = candidate
        else:
            if current:
                chunks.append(current)
            if len(s) <= max_len:
                current = s
            else:
                # Очень длинное предложение — режем по словам
                chunks.extend(_split_by_words(s, max_len))
                current = ""
    if current:
        chunks.append(current)
    return chunks


def _split_by_words(text: str, max_len: int) -> List[str]:
    """Split text into chunks ≤ max_len, on word boundaries when possible.

    For pathological words longer than max_len, the word is split into
    consecutive max_len-sized pieces — no data is dropped.
    """
    words = text.split(" ")
    chunks: List[str] = []
    current = ""
    for w in words:
        candidate = w if not current else f"{current} {w}"
        if len(candidate) <= max_len:
            current = candidate
            continue
        if current:
            chunks.append(current)
            current = ""
        if len(w) <= max_len:
            current = w
        else:
            # Pathologically long word: emit max_len-sized pieces, keep last as current
            for i in range(0, len(w), max_len):
                piece = w[i:i + max_len]
                if i + max_len >= len(w):
                    current = piece
                else:
                    chunks.append(piece)
    if current:
        chunks.append(current)
    return chunks
```

- [ ] **Step 8: Run — длинные тесты PASS**

Run: `.venv/bin/pytest tests/test_format.py::test_chunks_split_by_paragraphs tests/test_format.py::test_chunks_preserve_paragraphs_on_boundary -v`
Expected: 2 PASS

- [ ] **Step 9: Тесты на edge cases и инвариант «не режем HTML»**

```python
def test_chunks_escape_explosion_still_fits():
    # Текст почти из одних амперсандов: после escape & → &amp; длина растёт в 5 раз.
    # Первый проход даст HTML > MAX_HTML_LENGTH — должна сработать рекурсия с меньшим md-target.
    para = "&" * 3400
    chunks = md_to_html_chunks(para)
    for chunk in chunks:
        assert len(chunk) <= MAX_HTML_LENGTH, f"chunk too long: {len(chunk)}"


def test_chunks_very_long_paragraph_sentence_split():
    sentences = [f"Sentence number {i} with extra padding text. " for i in range(200)]
    md = "".join(sentences)
    chunks = md_to_html_chunks(md)
    for chunk in chunks:
        assert len(chunk) <= MAX_HTML_LENGTH


def test_chunks_never_split_link_tag():
    # Длинная ссылка должна целиком оказаться в одном чанке — никогда не быть разорванной
    long_intro = "intro paragraph. " * 200
    link_md = "[click here](https://example.com/very/long/path?a=1&b=2&c=3&d=4)"
    long_outro = "outro paragraph. " * 200
    md = f"{long_intro}\n\n{link_md}\n\n{long_outro}"
    chunks = md_to_html_chunks(md)
    expected_a_tag = '<a href="https://example.com/very/long/path?a=1&amp;b=2&amp;c=3&amp;d=4">click here</a>'
    matches = [c for c in chunks if expected_a_tag in c]
    assert len(matches) == 1, f"link tag must appear intact in exactly one chunk, got chunks={[c[:80] for c in chunks]}"
    # Никакой чанк не содержит фрагмента <a без закрывающего </a>
    for c in chunks:
        a_opens = c.count("<a ")
        a_closes = c.count("</a>")
        assert a_opens == a_closes, f"unbalanced <a>/</a> in chunk: {c[:200]}"


def test_chunks_never_split_amp_entity():
    # Длинный текст с & в середине — &amp; должен попасть в один чанк целиком
    pre = "abc " * 800
    post = " def" * 800
    md = pre + "X&Y" + post
    chunks = md_to_html_chunks(md)
    joined = "".join(chunks)
    assert "X&amp;Y" in joined
    # Никакой чанк не оканчивается на «висящий» фрагмент сущности
    for c in chunks:
        assert not c.endswith("&"), f"chunk ends with bare &: {c[-30:]!r}"
        assert not c.endswith("&a"), f"chunk ends with partial entity: {c[-30:]!r}"
        assert not c.endswith("&am"), f"chunk ends with partial entity: {c[-30:]!r}"
        assert not c.endswith("&amp"), f"chunk ends with partial entity: {c[-30:]!r}"


def test_chunks_never_split_bold():
    # Длинный текст с bold-фрагментом в середине — <b>...</b> должен попасть в один чанк
    pre = "intro. " * 500
    post = " outro." * 500
    md = pre + "\n\n**important bold phrase**\n\n" + post
    chunks = md_to_html_chunks(md)
    matches = [c for c in chunks if "<b>important bold phrase</b>" in c]
    assert len(matches) == 1
    for c in chunks:
        b_opens = c.count("<b>")
        b_closes = c.count("</b>")
        assert b_opens == b_closes, f"unbalanced <b>/</b>: {c[:200]}"


def test_split_by_words_does_not_lose_long_word_data():
    # Прямая проверка _split_by_words: безумно длинное слово не должно теряться
    from mars_bot.format import _split_by_words
    word = "x" * 250
    chunks = _split_by_words(word, max_len=100)
    assert "".join(chunks) == word  # вся буква-в-букву сохранена
    assert all(len(c) <= 100 for c in chunks)
```

- [ ] **Step 10: Реализовать рекурсивный split (markdown only — HTML никогда не режется)**

Заменить заглушку `_split_html_safely` и обновить `md_to_html_chunks` так, чтобы инвариант «режем только markdown, HTML не трогаем» соблюдался безусловно. При первом проходе если после конвертации какой-то HTML-чанк всё ещё > 4000 char (например, абзац под завязку с амперсандами или длинными ссылками), функция рекурсивно мельчит **исходный markdown** этого куска вдвое и конвертирует заново. И так до пола `MIN_MD_TARGET`.

Добавить в `src/mars_bot/format.py` константу и переписать `md_to_html_chunks`:

```python
MIN_MD_TARGET = 500  # пол для рекурсивного дробления markdown


def md_to_html_chunks(md: str) -> List[str]:
    """Convert markdown to HTML chunks, each ≤ MAX_HTML_LENGTH chars.

    Invariant: HTML strings are never cut. Only markdown is split; each
    markdown piece is then converted to HTML. If a resulting HTML chunk
    is still too long, we recurse on the source markdown piece with a
    halved target and re-convert. Worst-case fallback: split markdown
    by sentences/words at a conservative target.
    """
    return _split_and_convert(md, TARGET_MD_LENGTH)


class ChunkingError(Exception):
    """Raised when markdown cannot be split into chunks that fit MAX_HTML_LENGTH
    even at the most conservative target. Should never happen in practice
    (worst-case escape ratio is 5x, and the safe target accounts for that),
    but failing loudly here is much better than sending an oversized chunk
    to Telegram and getting a 400 Bad Request with no clear cause."""


def _split_and_convert(md: str, target_md_length: int) -> List[str]:
    md_pieces = _split_markdown(md, target_md_length)
    out: List[str] = []
    for piece in md_pieces:
        html_chunk = md_to_html(piece)
        if len(html_chunk) <= MAX_HTML_LENGTH:
            out.append(html_chunk)
            continue
        # HTML still too long. Recurse on the SOURCE markdown with smaller target.
        smaller = target_md_length // 2
        if smaller >= MIN_MD_TARGET:
            out.extend(_split_and_convert(piece, smaller))
        else:
            # Floor reached. Conservative fallback that still cuts markdown only:
            # split by sentences then by words at MAX_HTML_LENGTH//5 (worst-case
            # ratio for & → &amp; is 5x). Each fragment is re-converted.
            safe_md = MAX_HTML_LENGTH // 5
            for sentence in _split_paragraph_by_sentences(piece, safe_md):
                for word_piece in _split_by_words(sentence, safe_md):
                    chunk = md_to_html(word_piece)
                    if len(chunk) > MAX_HTML_LENGTH:
                        # Defensive: with safe_md = MAX/5 this is mathematically
                        # impossible for our converter. If it ever happens — fail
                        # loudly so the caller learns about it, instead of letting
                        # Telegram reject the message with an opaque 400.
                        raise ChunkingError(
                            f"Could not fit markdown into Telegram limit even at "
                            f"safe_md={safe_md}: got HTML chunk of {len(chunk)} chars "
                            f"(limit {MAX_HTML_LENGTH}). Source piece (first 120 chars): "
                            f"{word_piece[:120]!r}"
                        )
                    out.append(chunk)
    return out
```

Главное:
- `_split_markdown` уже сделан в Step 7 — его не трогаем
- `_split_html_safely` удаляется (этого имени больше нет)
- Инвариант: **функция никогда не вызывает резку на HTML-строке**. Все split-операции применяются к markdown, потом конвертируем заново. Это исключает поломку `&amp;`, `<a href="...">`, `<b>...</b>`, которая случилась бы при наивной нарезке HTML.

- [ ] **Step 11: Run all chunk tests**

Run: `.venv/bin/pytest tests/test_format.py -v`
Expected: all PASS

- [ ] **Step 12: Commit**

```bash
git add src/mars_bot/format.py tests/test_format.py
git commit -m "feat: two-pass chunking for long Telegram messages"
```

---

## Task 5: `telegram.py` — обёртка над Bot API

**Files:**
- Create: `src/mars_bot/telegram.py`
- Create: `tests/test_telegram.py`

- [ ] **Step 1: Тест на успешную отправку (с моком requests)**

`tests/test_telegram.py`:

```python
import pytest
from unittest.mock import patch, MagicMock
from mars_bot.telegram import send_message, TelegramError


def _ok_response(result=None):
    resp = MagicMock()
    resp.status_code = 200
    resp.json.return_value = {"ok": True, "result": result or {"message_id": 1}}
    return resp


def test_send_message_success(mocker):
    mock_post = mocker.patch("mars_bot.telegram.requests.post", return_value=_ok_response())
    result = send_message("test-token", -100123, "hello")
    assert result["message_id"] == 1
    args, kwargs = mock_post.call_args
    assert args[0] == "https://api.telegram.org/bottest-token/sendMessage"
    assert kwargs["json"]["chat_id"] == -100123
    assert kwargs["json"]["text"] == "hello"
    assert kwargs["json"]["parse_mode"] == "HTML"
    assert kwargs["json"]["disable_web_page_preview"] is True
```

- [ ] **Step 2: Run — FAIL (ImportError)**

Run: `.venv/bin/pytest tests/test_telegram.py -v`
Expected: FAIL

- [ ] **Step 3: Реализовать `telegram.py`**

`src/mars_bot/telegram.py`:

```python
import requests

API_BASE = "https://api.telegram.org/bot"
DEFAULT_TIMEOUT = 15


class TelegramError(Exception):
    """Raised when Bot API returns ok=False or HTTP error."""


def send_message(
    token: str,
    chat_id: int,
    text: str,
    parse_mode: str = "HTML",
    disable_web_page_preview: bool = True,
) -> dict:
    url = f"{API_BASE}{token}/sendMessage"
    payload = {
        "chat_id": chat_id,
        "text": text,
        "parse_mode": parse_mode,
        "disable_web_page_preview": disable_web_page_preview,
    }
    try:
        resp = requests.post(url, json=payload, timeout=DEFAULT_TIMEOUT)
    except requests.RequestException as e:
        raise TelegramError(f"HTTP request failed: {e}") from e

    try:
        data = resp.json()
    except ValueError as e:
        raise TelegramError(f"Bot API returned non-JSON ({resp.status_code}): {resp.text[:200]}") from e

    if not data.get("ok"):
        desc = data.get("description", "unknown error")
        raise TelegramError(f"Bot API error {resp.status_code}: {desc}")

    return data["result"]
```

- [ ] **Step 4: Run — PASS**

Run: `.venv/bin/pytest tests/test_telegram.py::test_send_message_success -v`
Expected: PASS

- [ ] **Step 5: Тесты на ошибки API**

Добавить в `tests/test_telegram.py`:

```python
def _err_response(status: int, description: str):
    resp = MagicMock()
    resp.status_code = status
    resp.json.return_value = {"ok": False, "description": description}
    return resp


def test_send_message_403_forbidden(mocker):
    mocker.patch("mars_bot.telegram.requests.post", return_value=_err_response(403, "Forbidden: bot was kicked"))
    with pytest.raises(TelegramError, match="403.*Forbidden"):
        send_message("t", 1, "x")


def test_send_message_400_bad_html(mocker):
    mocker.patch("mars_bot.telegram.requests.post", return_value=_err_response(400, "Bad Request: can't parse entities"))
    with pytest.raises(TelegramError, match="400.*can't parse"):
        send_message("t", 1, "x")


def test_send_message_http_failure(mocker):
    import requests as _requests
    mocker.patch("mars_bot.telegram.requests.post", side_effect=_requests.ConnectionError("network down"))
    with pytest.raises(TelegramError, match="HTTP request failed"):
        send_message("t", 1, "x")
```

- [ ] **Step 6: Run all telegram tests**

Run: `.venv/bin/pytest tests/test_telegram.py -v`
Expected: 4 PASS

- [ ] **Step 7: Commit**

```bash
git add src/mars_bot/telegram.py tests/test_telegram.py
git commit -m "feat: Telegram Bot API send_message wrapper"
```

---

## Task 6: `sent_log.py` — лог отправок и rate-limit

**Files:**
- Create: `src/mars_bot/sent_log.py`
- Create: `tests/test_sent_log.py`

Один файл `sent.log` решает обе задачи: append-only лог для аудита и источник для подсчёта rate-limit.

Формат строки: `2026-06-05T12:34:56+00:00 | <alias> | <preview ≤200 chars без \n>\n`

- [ ] **Step 1: Тесты на append и подсчёт**

`tests/test_sent_log.py`:

```python
from datetime import datetime, timedelta, timezone
from pathlib import Path
from mars_bot.sent_log import append_log, count_recent_sends, RATE_LIMIT_PER_HOUR


def test_append_creates_file_and_dir(tmp_path: Path):
    log = tmp_path / "data" / "sent.log"
    append_log(log, "team", "hello world")
    assert log.exists()
    line = log.read_text().strip()
    assert " | team | hello world" in line


def test_append_preview_truncated_to_200(tmp_path: Path):
    log = tmp_path / "sent.log"
    long_text = "x" * 500
    append_log(log, "team", long_text)
    line = log.read_text().strip()
    preview = line.rsplit(" | ", 1)[1]
    assert len(preview) == 200


def test_append_strips_newlines_in_preview(tmp_path: Path):
    log = tmp_path / "sent.log"
    append_log(log, "team", "line1\nline2\nline3")
    line = log.read_text().strip()
    assert "\n" not in line.rsplit(" | ", 1)[1]


def test_count_recent_empty_file(tmp_path: Path):
    log = tmp_path / "sent.log"
    assert count_recent_sends(log) == 0


def test_count_recent_within_window(tmp_path: Path):
    log = tmp_path / "sent.log"
    append_log(log, "team", "a")
    append_log(log, "team", "b")
    append_log(log, "marketing", "c")
    assert count_recent_sends(log) == 3


def test_count_recent_excludes_old_entries(tmp_path: Path):
    log = tmp_path / "sent.log"
    old_ts = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
    fresh_ts = datetime.now(timezone.utc).isoformat()
    log.write_text(
        f"{old_ts} | team | old\n"
        f"{fresh_ts} | team | fresh\n"
    )
    assert count_recent_sends(log) == 1


def test_count_recent_ignores_malformed_lines(tmp_path: Path):
    log = tmp_path / "sent.log"
    fresh_ts = datetime.now(timezone.utc).isoformat()
    log.write_text(f"garbage line\n{fresh_ts} | team | ok\n\n")
    assert count_recent_sends(log) == 1


def test_rate_limit_constant_is_twenty():
    assert RATE_LIMIT_PER_HOUR == 20
```

- [ ] **Step 2: Run — FAIL**

Run: `.venv/bin/pytest tests/test_sent_log.py -v`
Expected: FAIL (ImportError)

- [ ] **Step 3: Реализация**

`src/mars_bot/sent_log.py`:

```python
from datetime import datetime, timedelta, timezone
from pathlib import Path

RATE_LIMIT_PER_HOUR = 20
PREVIEW_MAX = 200
WINDOW_SECONDS = 3600


def append_log(log_path: Path, chat_alias: str, text: str) -> None:
    log_path.parent.mkdir(parents=True, exist_ok=True)
    ts = datetime.now(timezone.utc).isoformat()
    preview = text.replace("\n", " ").replace("\r", " ")[:PREVIEW_MAX]
    line = f"{ts} | {chat_alias} | {preview}\n"
    with log_path.open("a", encoding="utf-8") as f:
        f.write(line)


def count_recent_sends(log_path: Path, window_seconds: int = WINDOW_SECONDS) -> int:
    if not log_path.exists():
        return 0
    cutoff = datetime.now(timezone.utc) - timedelta(seconds=window_seconds)
    count = 0
    with log_path.open(encoding="utf-8") as f:
        for raw in f:
            line = raw.rstrip("\n")
            if not line:
                continue
            head = line.split(" | ", 1)[0]
            try:
                ts = datetime.fromisoformat(head)
            except ValueError:
                continue
            if ts >= cutoff:
                count += 1
    return count
```

- [ ] **Step 4: Run all — PASS**

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

- [ ] **Step 5: Commit**

```bash
git add src/mars_bot/sent_log.py tests/test_sent_log.py
git commit -m "feat: sent.log append and rate-limit counter"
```

---

## Task 7: `cli.py` — argparse и чтение текста

**Files:**
- Create: `src/mars_bot/cli.py`
- Create: `tests/test_cli.py`

Только парсинг аргументов и получение текста (из `--text-file`, `--text` или stdin). Orchestration — в Task 8.

- [ ] **Step 1: Тесты на парсинг argparse**

`tests/test_cli.py`:

```python
import io
import pytest
from pathlib import Path
from mars_bot.cli import build_parser, resolve_text


def test_parser_requires_to():
    parser = build_parser()
    with pytest.raises(SystemExit):
        parser.parse_args(["send"])


def test_parser_send_with_text():
    parser = build_parser()
    ns = parser.parse_args(["send", "--to", "team", "--text", "hi"])
    assert ns.command == "send"
    assert ns.to == "team"
    assert ns.text == "hi"
    assert ns.text_file is None
    assert ns.dry_run is False


def test_parser_send_with_text_file(tmp_path: Path):
    f = tmp_path / "msg.md"
    f.write_text("hello")
    parser = build_parser()
    ns = parser.parse_args(["send", "--to", "team", "--text-file", str(f)])
    assert ns.text_file == f
    assert ns.text is None


def test_parser_send_dry_run():
    parser = build_parser()
    ns = parser.parse_args(["send", "--to", "team", "--text", "hi", "--dry-run"])
    assert ns.dry_run is True


def test_parser_text_and_text_file_mutually_exclusive():
    parser = build_parser()
    with pytest.raises(SystemExit):
        parser.parse_args(["send", "--to", "team", "--text", "a", "--text-file", "b"])
```

- [ ] **Step 2: Тесты на `resolve_text`**

```python
def test_resolve_text_from_string():
    assert resolve_text(text="hello", text_file=None, stdin=io.StringIO("")) == "hello"


def test_resolve_text_from_file(tmp_path: Path):
    f = tmp_path / "msg.md"
    f.write_text("from file")
    assert resolve_text(text=None, text_file=f, stdin=io.StringIO("")) == "from file"


def test_resolve_text_from_stdin():
    assert resolve_text(text=None, text_file=None, stdin=io.StringIO("piped")) == "piped"


def test_resolve_text_empty_raises():
    from mars_bot.cli import CLIError
    with pytest.raises(CLIError, match="empty"):
        resolve_text(text=None, text_file=None, stdin=io.StringIO("   \n\n"))
```

- [ ] **Step 3: Run — FAIL**

Run: `.venv/bin/pytest tests/test_cli.py -v`
Expected: FAIL

- [ ] **Step 4: Минимальная реализация**

`src/mars_bot/cli.py`:

```python
import argparse
import sys
from pathlib import Path
from typing import IO, Optional


class CLIError(Exception):
    pass


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="mars-bot")
    sub = parser.add_subparsers(dest="command", required=True)

    send = sub.add_parser("send", help="Send a message to a configured chat")
    send.add_argument("--to", required=True, help="Chat alias from chats.json")
    grp = send.add_mutually_exclusive_group()
    grp.add_argument("--text-file", type=Path, dest="text_file", help="Path to file containing message text")
    grp.add_argument("--text", type=str, help="Inline message text")
    send.add_argument("--dry-run", action="store_true", dest="dry_run", help="Print what would be sent without actually sending")

    return parser


def resolve_text(text: Optional[str], text_file: Optional[Path], stdin: IO[str]) -> str:
    if text is not None:
        body = text
    elif text_file is not None:
        body = text_file.read_text(encoding="utf-8")
    else:
        body = stdin.read()
    if not body.strip():
        raise CLIError("empty text — nothing to send")
    return body


def main(argv: Optional[list] = None) -> int:
    # Placeholder — orchestration lands in Task 8
    parser = build_parser()
    args = parser.parse_args(argv)
    print(f"Parsed: {args}")
    return 0
```

- [ ] **Step 5: Run — PASS**

Run: `.venv/bin/pytest tests/test_cli.py -v`
Expected: 9 PASS

- [ ] **Step 6: Commit**

```bash
git add src/mars_bot/cli.py tests/test_cli.py
git commit -m "feat: CLI argparse skeleton and text resolver"
```

---

## Task 8: `cli.py` — orchestration (главный flow)

**Files:**
- Modify: `src/mars_bot/cli.py`
- Modify: `tests/test_cli.py`

Соединяем всё: config → whitelist → rate-limit → формат → отправка → лог.

- [ ] **Step 1: Тесты на оркестрацию (всё мокаем)**

Добавить в `tests/test_cli.py`:

```python
import json
from mars_bot.cli import cmd_send


def _setup_project(tmp_path: Path, chats: dict = None):
    (tmp_path / ".env").write_text("TELEGRAM_BOT_TOKEN=test-token\n")
    (tmp_path / "chats.json").write_text(json.dumps(chats or {"team": -100123, "test": 42}))
    return tmp_path


def test_cmd_send_happy_path(tmp_path: Path, mocker):
    _setup_project(tmp_path)
    mock_send = mocker.patch("mars_bot.cli.send_message", return_value={"message_id": 1})
    parser = build_parser()
    args = parser.parse_args(["send", "--to", "team", "--text", "hello"])
    rc = cmd_send(args, project_root=tmp_path, stdin=io.StringIO(""))
    assert rc == 0
    mock_send.assert_called_once()
    call_kwargs = mock_send.call_args
    assert call_kwargs.args[0] == "test-token"
    assert call_kwargs.args[1] == -100123
    # Лог записан
    log_path = tmp_path / "data" / "sent.log"
    assert log_path.exists()
    assert "team" in log_path.read_text()


def test_cmd_send_unknown_alias(tmp_path: Path, mocker):
    _setup_project(tmp_path)
    mock_send = mocker.patch("mars_bot.cli.send_message")
    parser = build_parser()
    args = parser.parse_args(["send", "--to", "nope", "--text", "x"])
    rc = cmd_send(args, project_root=tmp_path, stdin=io.StringIO(""))
    assert rc != 0
    mock_send.assert_not_called()


def test_cmd_send_dry_run_does_not_call_api(tmp_path: Path, mocker, capsys):
    _setup_project(tmp_path)
    mock_send = mocker.patch("mars_bot.cli.send_message")
    parser = build_parser()
    args = parser.parse_args(["send", "--to", "team", "--text", "**bold**", "--dry-run"])
    rc = cmd_send(args, project_root=tmp_path, stdin=io.StringIO(""))
    assert rc == 0
    mock_send.assert_not_called()
    out = capsys.readouterr().out
    assert "<b>bold</b>" in out


def test_cmd_send_rate_limit_blocks_at_full(tmp_path: Path, mocker):
    _setup_project(tmp_path)
    mock_send = mocker.patch("mars_bot.cli.send_message", return_value={"message_id": 1})
    # 20 свежих записей — лимит уже выбран, любая отправка должна быть отклонена
    from datetime import datetime, timezone
    log_path = tmp_path / "data" / "sent.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    ts = datetime.now(timezone.utc).isoformat()
    log_path.write_text("".join(f"{ts} | team | x\n" for _ in range(20)))

    parser = build_parser()
    args = parser.parse_args(["send", "--to", "team", "--text", "x"])
    rc = cmd_send(args, project_root=tmp_path, stdin=io.StringIO(""))
    assert rc == 5
    mock_send.assert_not_called()


def test_cmd_send_rate_limit_counts_chunks_not_calls(tmp_path: Path, mocker):
    """Лимит — на количество TG-сообщений, а не на вызовы CLI.
    Если длинный текст режется на 3 чанка, отправка засчитывается как 3."""
    _setup_project(tmp_path)
    mock_send = mocker.patch("mars_bot.cli.send_message", return_value={"message_id": 1})
    # 18 свежих записей в логе. Длинный текст должен дать >= 3 чанков → 18+3 > 20 → reject.
    from datetime import datetime, timezone
    log_path = tmp_path / "data" / "sent.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    ts = datetime.now(timezone.utc).isoformat()
    log_path.write_text("".join(f"{ts} | team | x\n" for _ in range(18)))

    long_text = "\n\n".join(["x" * 1500 for _ in range(4)])
    # Подтверждаем число чанков детерминированно
    from mars_bot.format import md_to_html_chunks
    expected_chunks = len(md_to_html_chunks(long_text))
    assert expected_chunks >= 3, "test setup expects >=3 chunks from this text"

    parser = build_parser()
    args = parser.parse_args(["send", "--to", "team", "--text", long_text])
    rc = cmd_send(args, project_root=tmp_path, stdin=io.StringIO(""))
    assert rc == 5
    mock_send.assert_not_called()


def test_cmd_send_splits_long_message_and_logs_per_chunk(tmp_path: Path, mocker):
    _setup_project(tmp_path)
    mock_send = mocker.patch("mars_bot.cli.send_message", return_value={"message_id": 1})
    long_text = "\n\n".join(["x" * 1500 for _ in range(4)])
    parser = build_parser()
    args = parser.parse_args(["send", "--to", "team", "--text", long_text])
    rc = cmd_send(args, project_root=tmp_path, stdin=io.StringIO(""))
    assert rc == 0

    from mars_bot.format import md_to_html_chunks
    expected = len(md_to_html_chunks(long_text))
    assert mock_send.call_count == expected

    # В лог пишем строку на каждый успешно отправленный чанк
    log_path = tmp_path / "data" / "sent.log"
    lines = [l for l in log_path.read_text().splitlines() if l.strip()]
    assert len(lines) == expected


def test_cmd_send_partial_failure_logs_successful_chunks(tmp_path: Path, mocker):
    """Если третий чанк падает, первые два должны быть залогированы, stderr показывает прогресс."""
    _setup_project(tmp_path)
    from mars_bot.telegram import TelegramError
    call_count = {"n": 0}

    def fake_send(*_args, **_kwargs):
        call_count["n"] += 1
        if call_count["n"] >= 3:
            raise TelegramError("simulated failure on chunk 3")
        return {"message_id": call_count["n"]}

    mocker.patch("mars_bot.cli.send_message", side_effect=fake_send)
    long_text = "\n\n".join(["x" * 1500 for _ in range(4)])
    parser = build_parser()
    args = parser.parse_args(["send", "--to", "team", "--text", long_text])
    rc = cmd_send(args, project_root=tmp_path, stdin=io.StringIO(""))
    assert rc == 6  # TelegramError

    log_path = tmp_path / "data" / "sent.log"
    assert log_path.exists()
    lines = [l for l in log_path.read_text().splitlines() if l.strip()]
    assert len(lines) == 2  # ровно два успешно отправленных чанка
```

- [ ] **Step 2: Run — FAIL**

Run: `.venv/bin/pytest tests/test_cli.py -v -k cmd_send`
Expected: FAIL (cmd_send not defined)

- [ ] **Step 3: Реализовать `cmd_send` и подключить к `main`**

Заменить `src/mars_bot/cli.py`:

```python
import argparse
import sys
from pathlib import Path
from typing import IO, Optional

from mars_bot.config import load_config, ConfigError
from mars_bot.format import md_to_html_chunks, ChunkingError
from mars_bot.sent_log import append_log, count_recent_sends, RATE_LIMIT_PER_HOUR
from mars_bot.telegram import send_message, TelegramError


class CLIError(Exception):
    pass


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="mars-bot")
    sub = parser.add_subparsers(dest="command", required=True)

    send = sub.add_parser("send", help="Send a message to a configured chat")
    send.add_argument("--to", required=True, help="Chat alias from chats.json")
    grp = send.add_mutually_exclusive_group()
    grp.add_argument("--text-file", type=Path, dest="text_file")
    grp.add_argument("--text", type=str)
    send.add_argument("--dry-run", action="store_true", dest="dry_run")

    return parser


def resolve_text(text: Optional[str], text_file: Optional[Path], stdin: IO[str]) -> str:
    if text is not None:
        body = text
    elif text_file is not None:
        body = text_file.read_text(encoding="utf-8")
    else:
        body = stdin.read()
    if not body.strip():
        raise CLIError("empty text — nothing to send")
    return body


def cmd_send(args: argparse.Namespace, project_root: Path, stdin: IO[str]) -> int:
    try:
        cfg = load_config(project_root)
    except ConfigError as e:
        print(f"Config error: {e}", file=sys.stderr)
        return 2

    if args.to not in cfg.chats:
        available = ", ".join(sorted(cfg.chats.keys()))
        print(f"Unknown chat alias: {args.to!r}. Available: {available}", file=sys.stderr)
        return 3

    try:
        text = resolve_text(args.text, args.text_file, stdin)
    except CLIError as e:
        print(f"Input error: {e}", file=sys.stderr)
        return 4

    log_path = project_root / "data" / "sent.log"
    try:
        chunks = md_to_html_chunks(text)
    except ChunkingError as e:
        print(f"Chunking error: {e}", file=sys.stderr)
        return 7

    if args.dry_run:
        for i, chunk in enumerate(chunks, 1):
            print(f"--- chunk {i}/{len(chunks)} (to={args.to}, {len(chunk)} chars) ---")
            print(chunk)
        return 0

    # Rate limit is per-Telegram-message, not per-CLI-call: a long summary
    # that splits into N chunks counts as N messages.
    recent = count_recent_sends(log_path)
    if recent + len(chunks) > RATE_LIMIT_PER_HOUR:
        print(
            f"Rate limit would be exceeded: {recent} sends in the last hour, "
            f"this call would add {len(chunks)} more (limit {RATE_LIMIT_PER_HOUR}). "
            f"Wait or send shorter text.",
            file=sys.stderr,
        )
        return 5

    chat_id = cfg.chats[args.to]
    sent = 0
    try:
        for chunk in chunks:
            send_message(cfg.bot_token, chat_id, chunk)
            # Log after each successful send so partial-failure state is preserved
            # and rate-limit counter stays accurate.
            append_log(log_path, args.to, chunk)
            sent += 1
    except TelegramError as e:
        print(
            f"Telegram error after sending {sent}/{len(chunks)} chunks: {e}",
            file=sys.stderr,
        )
        return 6

    print(f"Sent {sent} message(s) to {args.to}")
    return 0


def main(argv: Optional[list] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    project_root = Path(__file__).resolve().parents[2]
    if args.command == "send":
        return cmd_send(args, project_root=project_root, stdin=sys.stdin)
    return 1


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

- [ ] **Step 4: Run all CLI tests**

Run: `.venv/bin/pytest tests/test_cli.py -v`
Expected: все тесты PASS (примерно 16 — 9 от Task 7 + 7 новых в этой задаче: happy_path, unknown_alias, dry_run, rate_limit_blocks_at_full, rate_limit_counts_chunks_not_calls, splits_long_message_and_logs_per_chunk, partial_failure_logs_successful_chunks). Точное число сверяем по выводу pytest, не по арифметике в плане.

- [ ] **Step 5: Run all tests**

Run: `.venv/bin/pytest -v`
Expected: всё зелёное

- [ ] **Step 6: Commit**

```bash
git add src/mars_bot/cli.py tests/test_cli.py
git commit -m "feat: CLI orchestration (config, whitelist, rate-limit, send, log)"
```

---

## Task 9: `scripts/get_chat_ids.py` — утилита setup'а

**Files:**
- Create: `scripts/get_chat_ids.py`

Не покрываем юнит-тестами — это одноразовый setup-скрипт, который реально дёргает Bot API. Smoke-test пользователь делает руками на этапе setup'а.

- [ ] **Step 1: Создать скрипт**

`scripts/get_chat_ids.py`:

```python
#!/usr/bin/env python3
"""Discover chat IDs the bot can currently see via getUpdates.

Run once after adding the bot to all Mars chats. Copy the printed
chat_id values into chats.json under the chosen aliases.
"""
import sys
from pathlib import Path

# Allow running without install: prepend src/ to path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))

import requests
from mars_bot.config import load_config, ConfigError


def main() -> int:
    try:
        cfg = load_config(ROOT)
    except ConfigError as e:
        print(f"Config error: {e}", file=sys.stderr)
        return 1

    url = f"https://api.telegram.org/bot{cfg.bot_token}/getUpdates"
    try:
        resp = requests.get(url, timeout=15)
        data = resp.json()
    except Exception as e:
        print(f"Bot API call failed: {e}", file=sys.stderr)
        return 1

    if not data.get("ok"):
        print(f"Bot API error: {data}", file=sys.stderr)
        return 1

    seen = {}
    for update in data.get("result", []):
        msg = (
            update.get("message")
            or update.get("channel_post")
            or update.get("edited_message")
            or update.get("edited_channel_post")
        )
        if not msg or "chat" not in msg:
            continue
        chat = msg["chat"]
        chat_id = chat["id"]
        title = (
            chat.get("title")
            or chat.get("username")
            or chat.get("first_name")
            or "Unknown"
        )
        seen[chat_id] = title

    if not seen:
        print("No chats found in getUpdates response.")
        print("Troubleshooting:")
        print("  - did the bot receive any message AFTER being added to the chats?")
        print("    (in groups: mention the bot or send /start@<botname>)")
        print("  - is a webhook configured? clear it:")
        print(f"      curl https://api.telegram.org/bot<TOKEN>/deleteWebhook")
        print("  - for groups: check privacy mode at @BotFather → /mybots → bot →")
        print("    Bot Settings → Group Privacy → Turn off, then re-trigger a message")
        return 2

    print("Discovered chats:")
    for cid, title in sorted(seen.items()):
        print(f"  {title!r:40s}  →  {cid}")
    print()
    print("Copy these chat_id values into chats.json under aliases of your choice.")
    return 0


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

- [ ] **Step 2: Smoke-check (без setup'а — должен сообщить о пустых результатах)**

Run: `.venv/bin/python scripts/get_chat_ids.py`
Expected: либо `Config error: .env not found` (если ты ещё не настроила .env), либо `No chats found in getUpdates response` с troubleshooting. Без падений со стек-трейсами.

- [ ] **Step 3: Commit**

```bash
git add scripts/get_chat_ids.py
git commit -m "feat: getUpdates discovery script for setup"
```

---

## Task 10: Slash-команда `/send-summary`

**Files:**
- Create: `/Users/nataliadudina/Desktop/ObsidianVault/.claude/commands/send-summary.md`

Slash-команда живёт в `.claude/commands/` корня vault (не в `mars-bot/`), потому что её использует Claude в любом чате с этим vault.

- [ ] **Step 1: Создать `.claude/commands/` если нет**

```bash
mkdir -p /Users/nataliadudina/Desktop/ObsidianVault/.claude/commands
```

- [ ] **Step 2: Записать инструкцию**

`/Users/nataliadudina/Desktop/ObsidianVault/.claude/commands/send-summary.md`:

````markdown
---
description: Сделать саммари митинга и отправить в Mars-чат через mars-bot
---

# /send-summary

Помоги Наташе сделать саммари митинга и отправить его в один из Mars-чатов через CLI `mars-bot`.

## Workflow

1. **Определи входные данные.**
   - Если пользователь указал файл — используй его.
   - Если файл не указан — посмотри последние 5 файлов в `projects/mars/meetings/` с префиксом `{mars} {transcript}` (сортировка по дате в имени, новые сверху), покажи нумерованный список, спроси какой.
   - Если чат не указан — прочитай ключи из `projects/vibe-coding/mars-bot/chats.json`, покажи список алиасов, спроси куда отправить.
   - Если пользователь дал несколько файлов и/или несколько чатов — обработай каждую пару, итерация по парам.

2. **Сгенерируй саммари.**
   - Прочитай транскрипт.
   - Напиши саммари по структуре эталона (см. ниже).
   - Тон: деловой, конспективный. Это рабочий документ для команды, не контент в канал. Skill `writing-natasha` тут не применяется.
   - Покажи превью саммари в чате.

3. **Review-gate.**
   - Дождись явного подтверждения от Наташи. Не отправляй до подтверждения.
   - Если просит поправить — итерируй: «убери X», «добавь блок Y», «короче» — переписывай и снова показывай превью.
   - Для batch'а (несколько саммари) — покажи все превью скопом, спроси одно общее «отправляй» или точечные правки.

4. **Отправь через CLI.**
   - Для каждой пары (саммари, чат_алиас):
     - Создай временный файл `/tmp/mars-bot-summary-<timestamp>.md` с текстом саммари
     - Вызови: `cd /Users/nataliadudina/Desktop/ObsidianVault/projects/vibe-coding/mars-bot && .venv/bin/mars-bot send --to <alias> --text-file /tmp/mars-bot-summary-<timestamp>.md`
     - **В finally удали временный файл — независимо от успеха или ошибки.**
   - Если CLI вернул ошибку — сообщи в чате (с stderr из CLI), не удаляй текст саммари из контекста: Наташа подтверждает заново, новый временный файл создаётся для повторной попытки.
   - Если успех — подтверди отправку.

## Структура саммари (эталон)

Целевая структура — лаконичный markdown-документ. Используй заголовки `##`, списки `-`, `**жирное**` для ключевых тезисов.

Базовый шаблон (адаптируй под содержание). **Первая строка — обязательно** в формате `📝 **Саммари: <тема> — YYYY-MM-DD**` (через `**bold**`, не через `#`): дизайн ожидает `📝` снаружи `<b>...</b>`, а наш конвертер `#` headings заворачивает в `<b>` целиком и эмодзи перед `#` не сохраняет. `## Главные боли` и т.п. внутри тела — нормальные heading'и.

```markdown
📝 **Саммари: <тема митинга> — YYYY-MM-DD**

Один абзац вводки: что обсуждали, общий тон.

## Главные боли

- **Тезис.** Раскрытие.
- ...

## Решения и идеи

### Подтема 1
- ...

### Подтема 2
- ...

## Action items

- Кто — что — когда (если в митинге звучало)
```

Не все секции обязательны — для каждого митинга выбирай уместные. Не выдумывай конкретику, которой нет в транскрипте (см. правило в памяти `feedback_no_invented_details.md`).

Хороший образец стиля и плотности: `projects/mars/meetings/{mars} {article} саммари ретро по доду – 2026-06-03.md`.

## Что НЕ делать

- Не сохранять саммари в vault как файл. Саммари живёт только в TG-чате после отправки.
- Не отправлять без явного подтверждения от Наташи.
- Не использовать heredoc / inline текст в bash при вызове CLI — только через `--text-file`. Текст может содержать `$`, кавычки, backticks; временный файл это решает.
- Не оставлять временный файл в `/tmp/` после вызова CLI — удалить в finally.
- Если транскрипт обрывистый или непонятный — не додумывать. Скажи в чате что нашлось, спроси Наташу.
````

- [ ] **Step 3: Commit (в vault'е этого файла нет git — но в mars-bot тоже не коммитим, файл не в его дереве)**

Этот файл живёт вне `mars-bot/`. Vault не под git, отдельный коммит не нужен. Просто оставляем файл на диске.

---

## Task 11: README — setup, troubleshooting, emergency stop

**Files:**
- Modify: `projects/vibe-coding/mars-bot/README.md`

- [ ] **Step 1: Записать полный README**

`projects/vibe-coding/mars-bot/README.md`:

````markdown
# mars-bot

CLI for sending markdown text to Mars Telegram chats via Bot API. Used together with the `/send-summary` slash command in Claude to deliver meeting summaries to the right team chat.

Design: `docs/{vibe-coding} {plan} mars-bot mvp дизайн – 2026-06-05.md`
Implementation plan: `docs/{vibe-coding} {plan} mars-bot mvp имплементация – 2026-06-05.md`

## Architecture

- Local CLI on Mac, no long-running process
- Markdown → Telegram-compatible HTML conversion (custom, no external markdown lib)
- Recursive markdown-only chunking: 3500-char markdown target by default; if the resulting HTML still exceeds 4000 chars (e.g. dense `&`/links), recurse on the **source markdown** with halved target and re-convert. **HTML strings are never split** — invariant that prevents tearing `&amp;`, `<a href="...">`, `<b>...</b>` at chunk boundaries
- File-backed rate limit via `data/sent.log` — counts **Telegram messages, not CLI calls**: a long summary split into N chunks counts as N (limit 20/hour). One log line per successfully sent chunk; partial failures preserve what was sent
- Alias whitelist via `chats.json` — CLI refuses unknown aliases without calling the API

## Local setup

```bash
cd projects/vibe-coding/mars-bot
python3.11 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest
```

## Configuration

Two files, both gitignored:

`.env`:
```
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
```

`chats.json`:
```json
{
  "team": -100123456789,
  "marketing": -100987654321,
  "ops": -100111222333,
  "test": 12345678
}
```

`test` — your personal DM with the bot. Use it for smoke tests.

## First-time setup

1. Create the bot via @BotFather: `/newbot` → name → username → copy the token.
2. `cp .env.example .env` and paste the token.
3. Add the bot as a member to each Mars chat (you need admin rights to add bots).
4. In each chat, after adding the bot, send at least one message (in groups: mention the bot or `/start@<botname>`) — otherwise the chat will not appear in `getUpdates`.
5. In your DM with the bot, send `/start` so the `test` alias becomes discoverable.
6. Run: `.venv/bin/python scripts/get_chat_ids.py`
7. Copy chat IDs from the output into `chats.json` under aliases of your choice (`cp chats.json.example chats.json` first).
8. Smoke-test: `.venv/bin/mars-bot send --to test --text "hey"` — should arrive in your DM.

## Setup troubleshooting

- **`getUpdates` returns 409 Conflict or an empty list.** A webhook may be set. Clear it:
  ```
  curl https://api.telegram.org/bot<TOKEN>/deleteWebhook
  ```
  Then re-run `get_chat_ids.py`.
- **The `test` DM does not appear.** Send `/start` to the bot in your private chat, then re-run.
- **A group chat does not appear.** Two checks: (a) mention the bot or use `/start@<botname>` in the group; (b) if that doesn't help — @BotFather → `/mybots` → bot → `Bot Settings` → `Group Privacy` → `Turn off`. Then re-trigger a message in the group and re-run.
- **`send` returns 403 Forbidden at runtime.** The bot was removed from the chat or its right to write was revoked. Add the bot back.

## Usage

```bash
# Inline text
.venv/bin/mars-bot send --to team --text "**hello** team"

# From file
.venv/bin/mars-bot send --to team --text-file /tmp/summary.md

# From stdin (no --text or --text-file)
echo "**hello**" | .venv/bin/mars-bot send --to team

# Preview without sending
.venv/bin/mars-bot send --to team --text-file /tmp/summary.md --dry-run
```

Exit codes: 0 success, 2 config error, 3 unknown alias, 4 empty input, 5 rate limit, 6 Telegram error, 7 chunking error (markdown couldn't be split to fit TG limit even at the conservative fallback — should be unreachable in practice; if you ever see this, the input is pathological).

## Emergency stop

If the token leaks, the bot is misbehaving, or you need to immediately stop ALL sends:

1. Open @BotFather in Telegram
2. `/mybots` → choose `mars-bot` → `API Token` → `Revoke current token`
3. The old token is dead. No code (including this CLI and anyone else's) can send as the bot.
4. When resolved, generate a new token via @BotFather and update `.env`. The bot itself remains a member of all chats; only the token rotates.

## Tests

```bash
.venv/bin/pytest -v
```

All tests are offline — Telegram Bot API is mocked. Real send only happens via the smoke-test on step 8 of setup or by manually running `send` with a configured chat.

## Files

```
src/mars_bot/
  config.py          # .env + chats.json loader
  format.py          # md → HTML, two-pass chunking
  telegram.py        # Bot API sendMessage wrapper
  sent_log.py        # append + rate-limit counter
  cli.py             # argparse + cmd_send orchestration
scripts/
  get_chat_ids.py    # one-shot getUpdates discovery
data/
  sent.log           # append-only log + rate-limit source (gitignored)
```
````

- [ ] **Step 2: Commit**

```bash
cd /Users/nataliadudina/Desktop/ObsidianVault/projects/vibe-coding/mars-bot
git add README.md
git commit -m "docs: full README with setup, troubleshooting, emergency stop"
```

---

## Task 12: End-to-end smoke test (manual)

Не код, а проверка работоспособности на реальном TG-боте после setup'а.

- [ ] **Step 1: Сделать `/newbot` у @BotFather, получить токен, положить в `.env`**

- [ ] **Step 2: Создать тестовую группу в TG («mars-bot test»), добавить бота, упомянуть его (`@<botname> привет`)**

- [ ] **Step 3: Открыть личку с ботом, нажать /start**

- [ ] **Step 4: Запустить `get_chat_ids.py`, скопировать ID личного чата и тестовой группы в `chats.json` под алиасами `test` и `team`**

- [ ] **Step 5: Smoke-test форматирования (без отправки)**

CLI читает stdin, когда не передан ни `--text-file`, ни `--text` — поэтому heredoc подаём через pipe.

```bash
.venv/bin/mars-bot send --to test --dry-run << 'EOF'
# Заголовок

Параграф с **жирным** и *курсивом*, и [ссылкой](https://example.com?a=1&b=2).

- Пункт раз
- Пункт два
EOF
```

Expected: stdout содержит `<b>Заголовок</b>`, `<b>жирным</b>`, `<i>курсивом</i>`, `<a href="https://example.com?a=1&amp;b=2">ссылкой</a>`, `• Пункт раз`.

Heredoc используется только для smoke-теста — продакшен-путь идёт через slash-команду и временный файл в `/tmp/`, чтобы избежать проблем с экранированием.

- [ ] **Step 6: Реальная отправка в личку**

```bash
.venv/bin/mars-bot send --to test --text "**smoke test** работает"
```

Expected: в личке от бота приходит сообщение «**smoke test** работает» (жирный шрифт), CLI печатает `Sent 1 message(s) to test`.

- [ ] **Step 7: Проверить лог**

```bash
cat data/sent.log
```

Expected: одна строка вида `2026-06-05T12:34:56+00:00 | test | **smoke test** работает`.

- [ ] **Step 8: Проверить whitelist (негативный кейс)**

```bash
.venv/bin/mars-bot send --to nonexistent --text "x"
```

Expected: stderr `Unknown chat alias: 'nonexistent'. Available: team, test`, exit code 3, никаких HTTP-вызовов.

- [ ] **Step 9: Проверить rate-limit (опционально, долго)**

Запустить `mars-bot send --to test --text "rate test N"` 21 раз подряд в течение часа. На 21-й должен прийти отказ rate-limit.

- [ ] **Step 10: Проверить slash-команду `/send-summary`**

В Claude чате с этим vault: `/send-summary` → следовать диалогу — выбрать митинг, посмотреть превью, отправить в `test`. Убедиться что:
- Saммари показалось в чате до отправки
- Сообщение пришло в TG
- Временный файл в `/tmp/mars-bot-summary-*.md` удалён после отправки (`ls /tmp/mars-bot-summary-* 2>/dev/null` — ничего)

---

## Self-review checklist (выполнено)

- **Spec coverage:**
  - Workflow → Task 10 (slash-команда) + Task 8 (CLI orchestration)
  - Структура репо → Task 1
  - CLI интерфейс (`send --to <alias> [...]`) → Task 7, 8
  - Конфигурация `.env` + `chats.json` → Task 1, 2
  - Setup → Task 11 (README), Task 12 (manual smoke)
  - Setup troubleshooting → Task 9 (вывод скрипта), Task 11 (README)
  - Формат отправки HTML + заголовок с 📝 → Task 3 (HTML), Task 10 (📝 в саммари slash-команды)
  - Дробление двухпроходное → Task 4
  - Whitelist → Task 8 (cmd_send)
  - Лог отправок → Task 6, Task 8
  - Rate-limit через sent.log → Task 6, Task 8
  - Emergency stop / revoke токена → Task 11 (README)
  - Тесты → Task 2-8 (юнит), Task 12 (smoke)
  - Lifecycle временного файла → Task 10 (slash-команда инструкция)

- **Placeholder scan:** все шаги содержат код, команды и ожидаемый результат. Никаких TBD/TODO.

- **Type consistency:** `Config(bot_token, chats)`, `md_to_html()`, `md_to_html_chunks()`, `MAX_HTML_LENGTH=4000`, `TARGET_MD_LENGTH=3500`, `RATE_LIMIT_PER_HOUR=20`, `append_log(log_path, alias, text)`, `count_recent_sends(log_path)`, `send_message(token, chat_id, text)`, `TelegramError`, `CLIError`, `ConfigError` — все имена и сигнатуры согласованы между задачами.

Два намеренных момента, оба явно зафиксированы:

1. **📝 в шаблоне саммари.** Дизайн ожидает заголовок вида `📝 <b>Саммари: ...</b>` (эмодзи снаружи bold). Шаблон в Task 10 формирует первую строку как `📝 **Саммари: ... — YYYY-MM-DD**` (через `**bold**`, не через `#`-heading), потому что `#`-heading у нашего конвертера заворачивает всю строку в `<b>...</b>` целиком и эмодзи перед `#` не отрабатывается специально. В CLI/format.py 📝 не зашит — он живёт в инструкции slash-команды.

2. **Списки `-`/`*` → `•`.** Дизайн обновлён под это поведение (см. секцию «Формат отправки в TG» в спеке). Буллеты читабельнее в TG, чем строчный дефис. Отличие зафиксировано в комментариях к тестам и в дизайне.
