From caaba36a0ce65e5ecd5fb80f9cf6f85fad99190c Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 2 Aug 2026 23:17:42 +0300 Subject: [PATCH] feat: add Telegram secretary bot --- .env.example | 8 + .gitignore | 3 + README.md | 38 ++ bot.py | 411 ++++++++++++++++++ docs/PROJECT_STATUS.md | 73 ++-- .../decisions/001-mvp-boundaries-and-rules.md | 4 +- test_bot.py | 175 ++++++++ 7 files changed, 680 insertions(+), 32 deletions(-) create mode 100644 .env.example create mode 100644 README.md create mode 100755 bot.py create mode 100644 test_bot.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3e4d784 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +TELEGRAM_BOT_TOKEN=replace-me +TELEGRAM_CHAT_ID=-1004242041275 +TELEGRAM_OWNER_ID=87262245 +TELEGRAM_REPORT_THREAD_ID=37 +TELEGRAM_ALLOWED_THREAD_IDS=0,2,4,6,8 +BOT_DATABASE=data/secretary.sqlite3 +APP_TIMEZONE=Asia/Bishkek +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index c31c894..f617510 100755 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ __pycache__/ .env .env.* !.env.example + +# Local bot archive +data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce93578 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Акылдаш — бот-секретарь + +Версия: `0.2.0` + +Бот сохраняет сообщения разрешённых тем Telegram в локальную SQLite-базу и +выгружает обсуждения в Markdown. Внешние Python-зависимости не требуются. + +## Запуск + +Требуется Python 3.11 или новее. + +```bash +export TELEGRAM_BOT_TOKEN='...' +export TELEGRAM_CHAT_ID='-1004242041275' +export TELEGRAM_OWNER_ID='87262245' +export TELEGRAM_REPORT_THREAD_ID='37' +export TELEGRAM_ALLOWED_THREAD_IDS='0,2,4,6,8' +python3 bot.py +``` + +Доступные команды: `/help`, `/status`, `/export [дней]`, `/export все`. +Экспорт доступен только пользователю с Telegram ID из `TELEGRAM_OWNER_ID`. +Markdown публикуется в теме `TELEGRAM_REPORT_THREAD_ID`, а в исходной теме +остаётся отсечка со ссылкой и общим хэштегом отчёта. + +База по умолчанию хранится в `data/secretary.sqlite3`. Сообщения из других +групп и тем не сохраняются. Полный ответ Telegram сохраняется в базе, поэтому +метаданные вложений остаются доступными для последующего скачивания. + +## Проверка + +```bash +python3 -m unittest -v +``` + +--- + +Акылдаш v0.2.0 diff --git a/bot.py b/bot.py new file mode 100755 index 0000000..84233e8 --- /dev/null +++ b/bot.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python3 +"""Minimal Telegram secretary: archive allowed topics and export them to Markdown.""" + +from __future__ import annotations + +import json +import logging +import os +import sqlite3 +import time +import urllib.error +import urllib.request +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + +APP_NAME = "Акылдаш" +APP_VERSION = "0.2.0" +FOOTER = f"{APP_NAME} v{APP_VERSION}" + + +@dataclass(frozen=True) +class Config: + token: str + chat_id: int + owner_id: int + report_thread_id: int + thread_ids: frozenset[int] + database: Path + timezone: ZoneInfo + + +def load_config() -> Config: + token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() + chat_id = os.environ.get("TELEGRAM_CHAT_ID", "").strip() + owner_id = os.environ.get("TELEGRAM_OWNER_ID", "").strip() + report_thread_id = os.environ.get("TELEGRAM_REPORT_THREAD_ID", "").strip() + if not token or not chat_id or not owner_id or not report_thread_id: + raise SystemExit( + "Set TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_OWNER_ID " + "and TELEGRAM_REPORT_THREAD_ID" + ) + + try: + threads = frozenset( + int(value.strip()) + for value in os.environ.get( + "TELEGRAM_ALLOWED_THREAD_IDS", "0,2,4,6,8" + ).split(",") + if value.strip() + ) + return Config( + token=token, + chat_id=int(chat_id), + owner_id=int(owner_id), + report_thread_id=int(report_thread_id), + thread_ids=threads, + database=Path(os.environ.get("BOT_DATABASE", "data/secretary.sqlite3")), + timezone=ZoneInfo(os.environ.get("APP_TIMEZONE", "Asia/Bishkek")), + ) + except (ValueError, ZoneInfoNotFoundError) as error: + raise SystemExit(f"Invalid configuration: {error}") from error + + +def connect(database: Path) -> sqlite3.Connection: + database.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(database) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA journal_mode=WAL") + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS messages ( + chat_id INTEGER NOT NULL, + message_id INTEGER NOT NULL, + thread_id INTEGER NOT NULL, + sent_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + update_id INTEGER NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (chat_id, message_id) + ); + CREATE INDEX IF NOT EXISTS messages_thread_date + ON messages (chat_id, thread_id, sent_at); + CREATE TABLE IF NOT EXISTS state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + """ + ) + return connection + + +def archive_update( + connection: sqlite3.Connection, update: dict, config: Config +) -> dict | None: + update_id = int(update["update_id"]) + message = update.get("message") or update.get("edited_message") + + with connection: + if message: + chat_id = int(message["chat"]["id"]) + thread_id = int(message.get("message_thread_id", 0)) + if chat_id == config.chat_id and thread_id in config.thread_ids: + connection.execute( + """ + INSERT INTO messages ( + chat_id, message_id, thread_id, sent_at, updated_at, + update_id, payload + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (chat_id, message_id) DO UPDATE SET + thread_id = excluded.thread_id, + updated_at = excluded.updated_at, + update_id = excluded.update_id, + payload = excluded.payload + """, + ( + chat_id, + int(message["message_id"]), + thread_id, + int(message["date"]), + int(message.get("edit_date", message["date"])), + update_id, + json.dumps(message, ensure_ascii=False), + ), + ) + else: + message = None + + connection.execute( + """ + INSERT INTO state (key, value) VALUES ('next_update_id', ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value + """, + (str(update_id + 1),), + ) + return message + + +def next_update_id(connection: sqlite3.Connection) -> int: + row = connection.execute( + "SELECT value FROM state WHERE key = 'next_update_id'" + ).fetchone() + return int(row["value"]) if row else 0 + + +def telegram_request( + config: Config, method: str, payload: dict, timeout: int = 65 +) -> object: + request = urllib.request.Request( + f"https://api.telegram.org/bot{config.token}/{method}", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + result = json.load(response) + if not result.get("ok"): + raise RuntimeError(result.get("description", "Telegram API error")) + return result["result"] + + +def send_text(config: Config, message: dict, text: str) -> None: + payload = { + "chat_id": config.chat_id, + "text": f"{text}\n\n{FOOTER}", + "reply_parameters": {"message_id": message["message_id"]}, + } + if message.get("message_thread_id"): + payload["message_thread_id"] = message["message_thread_id"] + telegram_request(config, "sendMessage", payload) + + +def send_document( + config: Config, thread_id: int, filename: str, content: bytes, caption: str +) -> dict: + boundary = uuid.uuid4().hex + fields = { + "chat_id": str(config.chat_id), + "message_thread_id": str(thread_id), + "caption": caption, + } + + body = bytearray() + for name, value in fields.items(): + body.extend( + f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"" + f"\r\n\r\n{value}\r\n".encode() + ) + body.extend( + f"--{boundary}\r\nContent-Disposition: form-data; name=\"document\"; " + f"filename=\"{filename}\"\r\nContent-Type: text/markdown; charset=utf-8" + f"\r\n\r\n".encode() + ) + body.extend(content) + body.extend(f"\r\n--{boundary}--\r\n".encode()) + + request = urllib.request.Request( + f"https://api.telegram.org/bot{config.token}/sendDocument", + data=bytes(body), + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + ) + with urllib.request.urlopen(request, timeout=65) as response: + result = json.load(response) + if not result.get("ok"): + raise RuntimeError(result.get("description", "Telegram API error")) + return result["result"] + + +def author(message: dict) -> str: + sender = message.get("from", {}) + name = " ".join( + part for part in (sender.get("first_name"), sender.get("last_name")) if part + ) + username = sender.get("username") + if name and username: + return f"{name} (@{username})" + return name or (f"@{username}" if username else "Неизвестный участник") + + +def attachment_descriptions(message: dict) -> list[str]: + descriptions: list[str] = [] + if message.get("photo"): + descriptions.append("фотография") + for field, label in ( + ("document", "документ"), + ("video", "видео"), + ("audio", "аудио"), + ("voice", "голосовое сообщение"), + ("animation", "анимация"), + ("sticker", "стикер"), + ): + attachment = message.get(field) + if attachment: + name = attachment.get("file_name") or attachment.get("emoji") + descriptions.append(f"{label}: {name}" if name else label) + return descriptions + + +def message_link(chat_id: int, message_id: int) -> str: + return f"https://t.me/c/{str(chat_id).removeprefix('-100')}/{message_id}" + + +def export_markdown( + connection: sqlite3.Connection, + config: Config, + thread_id: int, + days: int | None, + now: int | None = None, +) -> str: + parameters: list[int] = [config.chat_id, thread_id] + condition = "" + if days is not None: + condition = " AND sent_at >= ?" + parameters.append((now or int(time.time())) - days * 86400) + rows = connection.execute( + f""" + SELECT message_id, sent_at, payload FROM messages + WHERE chat_id = ? AND thread_id = ?{condition} + ORDER BY sent_at, message_id + """, + parameters, + ).fetchall() + + period = "за всё время" if days is None else f"за последние {days} дн." + lines = [ + "# Обсуждение", + "", + f"Тема: `{thread_id or 'Общее'}` ", + f"Период: {period} ", + f"Экспортировано: {datetime.now(config.timezone):%Y-%m-%d %H:%M %Z}", + "", + "## Сообщения", + ] + for row in rows: + message = json.loads(row["payload"]) + text = message.get("text") or message.get("caption") or "[Служебное событие Telegram]" + if text.split(maxsplit=1)[0].split("@", 1)[0] in {"/help", "/status", "/export"}: + continue + sent_at = datetime.fromtimestamp(row["sent_at"], timezone.utc).astimezone( + config.timezone + ) + lines.extend( + [ + "", + f"### {author(message)} — {sent_at:%Y-%m-%d %H:%M}", + "", + f"[Открыть сообщение]({message_link(config.chat_id, row['message_id'])})", + ] + ) + reply = message.get("reply_to_message", {}).get("message_id") + if reply and reply != message.get("message_thread_id"): + lines.append(f"Ответ на сообщение: #{reply}") + lines.extend(["", text]) + attachments = attachment_descriptions(message) + if attachments: + lines.extend(["", "Вложения:", *[f"- {item}" for item in attachments]]) + + lines.extend(["", "---", FOOTER, ""]) + return "\n".join(lines) + + +def parse_export_days(parts: list[str]) -> int | None: + if len(parts) == 1: + return 7 + if parts[1].lower() in {"all", "все"}: + return None + days = int(parts[1]) + if not 1 <= days <= 3650: + raise ValueError + return days + + +def handle_command( + connection: sqlite3.Connection, config: Config, message: dict +) -> None: + text = message.get("text", "") + if not text.startswith("/"): + return + parts = text.split() + command = parts[0].split("@", 1)[0].lower() + thread_id = int(message.get("message_thread_id", 0)) + + if command == "/help": + send_text( + config, + message, + "Я сохраняю обсуждения этой группы.\n" + "/status — количество сохранённых сообщений\n" + "/export [дней] — экспорт текущей темы за 7 дней или указанный срок\n" + "/export все — экспорт текущей темы целиком", + ) + elif command == "/status": + topic_count = connection.execute( + "SELECT COUNT(*) FROM messages WHERE chat_id = ? AND thread_id = ?", + (config.chat_id, thread_id), + ).fetchone()[0] + total_count = connection.execute( + "SELECT COUNT(*) FROM messages WHERE chat_id = ?", (config.chat_id,) + ).fetchone()[0] + send_text( + config, + message, + f"Сохранено сообщений: {topic_count} в этой теме, {total_count} всего.", + ) + elif command == "/export": + if int(message.get("from", {}).get("id", 0)) != config.owner_id: + send_text(config, message, "Экспорт доступен только владельцу бота.") + return + try: + days = parse_export_days(parts) + except (ValueError, IndexError): + send_text(config, message, "Использование: /export [1–3650|все]") + return + markdown = export_markdown(connection, config, thread_id, days) + stamp = datetime.now(config.timezone).strftime("%Y%m%d-%H%M") + report_tag = f"#report_{message['message_id']}" + report = send_document( + config, + config.report_thread_id, + f"discussion-{thread_id}-{stamp}.md", + markdown.encode(), + f"{report_tag}\nИсходное обсуждение: " + f"{message_link(config.chat_id, message['message_id'])}\n\n{FOOTER}", + ) + send_text( + config, + message, + "━━━━━━━━━━━━━━━━\n" + "✅ ОБСУЖДЕНИЕ ЗАВЕРШЕНО\n" + f"{report_tag}\n" + f"Отчёт: {message_link(config.chat_id, report['message_id'])}\n" + "━━━━━━━━━━━━━━━━", + ) + + +def run() -> None: + config = load_config() + connection = connect(config.database) + logging.info("Starting %s with database %s", FOOTER, config.database) + + while True: + try: + updates = telegram_request( + config, + "getUpdates", + { + "offset": next_update_id(connection), + "timeout": 50, + "allowed_updates": ["message", "edited_message"], + }, + ) + for update in updates: + message = archive_update(connection, update, config) + if message: + handle_command(connection, config, message) + except (urllib.error.URLError, TimeoutError, RuntimeError, OSError, ValueError): + logging.exception("Polling failed; retrying in 5 seconds") + time.sleep(5) + + +if __name__ == "__main__": + logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(message)s", + ) + try: + run() + except KeyboardInterrupt: + logging.info("Stopped") diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 29ebead..2686397 100755 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,13 +1,15 @@ # Статус проекта -Последняя проверка: 2026-07-31 +Последняя проверка: 2026-08-02 Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов. +Версия приложения: `0.2.0` + ## Краткий итог -Telegram-инфраструктура подготовлена для начала разработки бота. Супергруппа и бот существуют, бот добавлен в группу, Privacy Mode отключён, темы созданы и проверены через Telegram Bot API. Исходный код бота пока не создан. +Telegram-инфраструктура и первая версия бота-секретаря подготовлены. Бот сохраняет сообщения разрешённых тем в SQLite, переживает перезапуск и экспортирует обсуждения в Markdown. -Ближайшая цель — реализовать минимальный архиватор сообщений с сохранением метаданных и Markdown-экспортом обсуждений. +Ближайшая цель — проверить полный цикл на реальном обсуждении и подготовить постоянный запуск бота. ## Уже сделано @@ -45,33 +47,35 @@ Telegram-инфраструктура подготовлена для начал - `credentials.txt` добавлен в `.gitignore`. - Секреты нельзя добавлять в Git, логи, экспорты или сообщения бота. -## Пока не сделано - ### Репозиторий и приложение -- Не создан исходный код бота. -- Не определена и не зафиксирована структура Python-проекта. -- Локальный каталог `.git` пуст; локальный Git-репозиторий ещё нужно корректно инициализировать или привязать к удалённому репозиторию Gitea. +- Реализован бот-секретарь версии `0.2.0` без внешних Python-зависимостей. +- Сообщения и полные Telegram-метаданные сохраняются в SQLite. +- Добавлены команды `/help`, `/status`, `/export [дней]` и `/export все`. +- Экспорт доступен только владельцу, указанному в `TELEGRAM_OWNER_ID`. +- Отчёты публикуются в закрытой теме `Отчеты` (`message_thread_id=37`). +- Исходное обсуждение завершается заметной отсечкой со ссылкой и хэштегом отчёта. +- Локальный Git-репозиторий восстановлен и привязан к Gitea. + +## Пока не сделано + +### Развёртывание + - Не настроены Docker-файлы для Synology. - Не настроен CI/CD. ### Хранилище -- Не подключена база данных. +- PostgreSQL не подключён; первая версия использует SQLite. - Не утверждена окончательная схема PostgreSQL. - Не определены сроки хранения сообщений, вложений и экспортов. -- Не определён согласованный список пользователей с доступом к архиву. ### Функции бота -- Приём и сохранение сообщений. -- Сохранение автора, даты, темы, ID сообщения и reply-связи. -- Сохранение доступных вложений. -- Ручная пометка сообщений для экспорта. -- Экспорт темы или диапазона в Markdown. +- Скачивание файлов вложений; сейчас сохраняются их Telegram-метаданные. +- Ручная пометка отдельных сообщений для экспорта. - Формирование готовой инструкции для ручной загрузки в ChatGPT. - Возврат отчёта пользователя в Telegram. -- Команды `/help`, `/status` и команда экспорта. ### Группа и рабочий процесс @@ -85,29 +89,37 @@ Telegram-инфраструктура подготовлена для начал Перед реализацией постоянного развёртывания нужно получить или принять решения по следующим вопросам: 1. Адрес и параметры PostgreSQL для разработки и Synology. -2. Имя и URL удалённого репозитория Gitea. -3. Список пользователей Telegram, которым разрешён экспорт и работа с архивом. -4. Правила хранения и удаления сообщений и вложений. -5. Способ запуска бота: long polling на старте или webhook после развёртывания. -6. Нужен ли SQLite для локальной разработки как временное хранилище до подключения PostgreSQL. +2. Правила хранения и удаления сообщений и вложений. +3. Нужен ли webhook после постоянного развёртывания; первая версия работает через long polling. ## Следующий этап -### Stage 2 — бот-архиватор +### Проверка бота-секретаря Рекомендуемый порядок: -1. Создать каркас Python-приложения и конфигурацию через переменные окружения. -2. Добавить фильтр группы `-1004242041275` и разрешённых тем `2`, `4`, `6`, `8`. -3. Реализовать приём сообщений и сохранение метаданных. -4. Добавить минимальную схему хранилища. -5. Реализовать ручную отметку сообщений. -6. Реализовать Markdown-экспорт с контекстом, авторами, датами, reply-связями и вложениями. -7. Добавить команды справки и состояния. -8. Проверить работу на реальном обсуждении и зафиксировать результат в этом документе. +1. Запустить бота с рабочими переменными окружения. +2. Отправить в разрешённую тему текст, ответ, правку сообщения и вложение. +3. Проверить `/status`, `/export 1` и `/export все`. +4. Перезапустить процесс и убедиться, что сообщения не дублируются. +5. После проверки подготовить постоянный запуск на Synology. ## История изменений статуса +### 2026-08-02 + +- Экспорт ограничен Telegram ID владельца бота. +- Технические ответы на корневое сообщение темы исключены из Markdown. +- Экспорт перенесён в тему `Отчеты`, добавлены отсечки и навигационные хэштеги. +- Проверена живая публикация отчёта и отсечки через Telegram Bot API. +- Версия приложения обновлена до `0.2.0`. + +### 2026-08-01 + +- Восстановлена связь локального каталога с репозиторием Gitea. +- Реализован бот-секретарь версии `0.1.0` с SQLite и Markdown-экспортом. +- Проверены токен `help_clerk_bot` и доступ к супергруппе `Акылдаш`. + ### 2026-07-31 - Создана супергруппа и добавлен бот. @@ -115,4 +127,3 @@ Telegram-инфраструктура подготовлена для начал - Определены ID тем: `2`, `4`, `6`, `8`. - Подтверждено отсутствие webhook. - Зафиксировано, что код бота и хранилище ещё не реализованы. - diff --git a/docs/decisions/001-mvp-boundaries-and-rules.md b/docs/decisions/001-mvp-boundaries-and-rules.md index 86097b9..ad2f282 100755 --- a/docs/decisions/001-mvp-boundaries-and-rules.md +++ b/docs/decisions/001-mvp-boundaries-and-rules.md @@ -11,7 +11,7 @@ Telegram не является хранилищем проекта, а бот и ## Что входит в MVP -- Telegram-супергруппа с темами `Общее` (стандартная тема), `MVP`, `Решения`, `Обсуждение` и `Работа с ИИ`. +- Telegram-супергруппа с темами `Общее` (стандартная тема), `MVP`, `Решения`, `Обсуждение`, `Работа с ИИ` и `Отчеты`. - Тема `MVP` используется только для обсуждения границ первой версии. После фиксации MVP тема закрывается. - Тема `Обсуждение` используется для рабочих гипотез и вопросов, которые ещё не стали подтверждёнными решениями. - В теме `Решения` бот публикует итоговые сводки и подтверждённые решения после завершения обсуждения. Сообщения бота являются единственным редактируемым источником итогового текста; пользователи не редактируют сообщения бота. @@ -20,6 +20,7 @@ Telegram не является хранилищем проекта, а бот и - Для сообщения сохраняются текст, автор, дата и время, тема, идентификатор сообщения и связь с ответом. - Экспорт выполняется по ручной отметке участника или по явно заданному диапазону темы. - Экспорт формируется в Markdown и включает контекст, сообщения по времени, ссылки и доступные вложения. +- Markdown публикуется в закрытой для сообщений участников теме `Отчеты`. В исходной теме бот оставляет заметную отсечку, прямую ссылку и общий с отчётом уникальный хэштег. - Участник вручную загружает экспорт в ChatGPT и возвращает результат в Telegram. - Gitea-уведомления на старте можно публиковать в `Общее` либо в отдельную тему после появления такой потребности. События ограничиваются PR, задачами, дедлайнами и сбоями важных проверок. - Подтверждённые решения и изменения документации фиксируются в Git/Gitea обычным review-процессом. @@ -46,6 +47,7 @@ Telegram не является хранилищем проекта, а бот и - Используются темы `MVP`, `Решения`, `Обсуждение` и `Работа с ИИ`; тема `Общее` остаётся стандартной. - `MVP` закрывается после фиксации состава первой версии. - Итог обсуждения формирует бот и публикует в `Решения`. +- Файлы экспортов публикуются в теме `Отчеты`; исходные темы не засоряются файлами. - `Работа с ИИ` предназначена только для публикаций бота и будет содержать best practice, а не обычный FAQ. - Остальные параметры первой версии из этого документа считаются утверждёнными, если команда не изменит их отдельным решением. diff --git a/test_bot.py b/test_bot.py new file mode 100644 index 0000000..3c7dca2 --- /dev/null +++ b/test_bot.py @@ -0,0 +1,175 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch +from zoneinfo import ZoneInfo + +from bot import ( + APP_VERSION, + Config, + archive_update, + connect, + export_markdown, + handle_command, + next_update_id, +) + + +class SecretaryTest(unittest.TestCase): + def test_archives_allowed_topic_and_exports_reply(self): + with tempfile.TemporaryDirectory() as directory: + config = Config( + token="test", + chat_id=-1004242041275, + owner_id=7, + report_thread_id=37, + thread_ids=frozenset({2}), + database=Path(directory) / "bot.sqlite3", + timezone=ZoneInfo("Asia/Bishkek"), + ) + connection = connect(config.database) + message = { + "message_id": 12, + "message_thread_id": 2, + "date": 1_700_000_000, + "chat": {"id": config.chat_id}, + "from": {"id": 7, "first_name": "Айжан"}, + "text": "Зафиксируем это решение.", + "reply_to_message": {"message_id": 11}, + } + + archived = archive_update( + connection, {"update_id": 40, "message": message}, config + ) + markdown = export_markdown( + connection, config, thread_id=2, days=None, now=1_700_000_001 + ) + + self.assertEqual(archived, message) + self.assertEqual(next_update_id(connection), 41) + self.assertIn("Айжан", markdown) + self.assertIn("Зафиксируем это решение.", markdown) + self.assertIn("Ответ на сообщение: #11", markdown) + self.assertIn(f"Акылдаш v{APP_VERSION}", markdown) + + def test_ignores_other_chat_but_advances_offset(self): + with tempfile.TemporaryDirectory() as directory: + config = Config( + token="test", + chat_id=-1, + owner_id=7, + report_thread_id=37, + thread_ids=frozenset({0}), + database=Path(directory) / "bot.sqlite3", + timezone=ZoneInfo("UTC"), + ) + connection = connect(config.database) + update = { + "update_id": 5, + "message": { + "message_id": 1, + "date": 1, + "chat": {"id": -2}, + "text": "Не наша группа", + }, + } + + self.assertIsNone(archive_update(connection, update, config)) + self.assertEqual(next_update_id(connection), 6) + self.assertEqual(connection.execute("SELECT COUNT(*) FROM messages").fetchone()[0], 0) + + def test_omits_forum_topic_root_reply_from_export(self): + with tempfile.TemporaryDirectory() as directory: + config = Config( + token="test", + chat_id=-1, + owner_id=7, + report_thread_id=37, + thread_ids=frozenset({2}), + database=Path(directory) / "bot.sqlite3", + timezone=ZoneInfo("UTC"), + ) + connection = connect(config.database) + message = { + "message_id": 12, + "message_thread_id": 2, + "date": 1, + "chat": {"id": config.chat_id}, + "from": {"id": config.owner_id}, + "text": "Сообщение темы", + "reply_to_message": {"message_id": 2}, + } + + archive_update(connection, {"update_id": 1, "message": message}, config) + markdown = export_markdown(connection, config, 2, None) + + self.assertNotIn("Ответ на сообщение: #2", markdown) + + def test_rejects_export_from_non_owner(self): + with tempfile.TemporaryDirectory() as directory: + config = Config( + token="test", + chat_id=-1, + owner_id=7, + report_thread_id=37, + thread_ids=frozenset({0}), + database=Path(directory) / "bot.sqlite3", + timezone=ZoneInfo("UTC"), + ) + connection = connect(config.database) + message = { + "message_id": 1, + "date": 1, + "chat": {"id": config.chat_id}, + "from": {"id": 8}, + "text": "/export все", + } + + with patch("bot.send_text") as send_text, patch( + "bot.send_document" + ) as send_document: + handle_command(connection, config, message) + + send_text.assert_called_once_with( + config, message, "Экспорт доступен только владельцу бота." + ) + send_document.assert_not_called() + + def test_sends_report_to_reports_topic_and_marks_discussion(self): + with tempfile.TemporaryDirectory() as directory: + config = Config( + token="test", + chat_id=-100123, + owner_id=7, + report_thread_id=37, + thread_ids=frozenset({2}), + database=Path(directory) / "bot.sqlite3", + timezone=ZoneInfo("UTC"), + ) + connection = connect(config.database) + message = { + "message_id": 12, + "message_thread_id": 2, + "date": 1, + "chat": {"id": config.chat_id}, + "from": {"id": config.owner_id}, + "text": "/export все", + } + + with patch("bot.send_text") as send_text, patch( + "bot.send_document", return_value={"message_id": 99} + ) as send_document: + handle_command(connection, config, message) + + document = send_document.call_args.args + self.assertEqual(document[1], config.report_thread_id) + self.assertIn("#report_12", document[4]) + self.assertIn("https://t.me/c/123/12", document[4]) + marker = send_text.call_args.args[2] + self.assertIn("ОБСУЖДЕНИЕ ЗАВЕРШЕНО", marker) + self.assertIn("#report_12", marker) + self.assertIn("https://t.me/c/123/99", marker) + + +if __name__ == "__main__": + unittest.main() -- 2.49.1