From d9b4f19af3ad466df299c6cee9b111069f01a43f Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 3 Aug 2026 00:49:35 +0300 Subject: [PATCH] feat: export messages after last cutoff --- README.md | 8 ++- TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md | 2 +- bot.py | 61 +++++++++++++++-- docs/PROJECT_STATUS.md | 10 +-- .../decisions/001-mvp-boundaries-and-rules.md | 2 +- docs/team/ai-skills-for-beginners.md | 2 +- test_bot.py | 65 ++++++++++++++++++- 7 files changed, 132 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 086d78c..c59eb31 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Акылдаш — бот-секретарь -Версия: `0.2.0` +Версия: `0.2.1` Бот сохраняет сообщения разрешённых тем Telegram в локальную SQLite-базу и выгружает обсуждения в Markdown. Внешние Python-зависимости не требуются. @@ -18,8 +18,10 @@ export TELEGRAM_ALLOWED_THREAD_IDS='0,2,4,6,8' python3 bot.py ``` -Доступные команды: `/help`, `/status`, `/export [дней]`, `/export все`. +Доступные команды: `/help`, `/status`, `/export`. Экспорт доступен только пользователю с Telegram ID из `TELEGRAM_OWNER_ID`. +Первый `/export` выгружает всю сохранённую тему, последующие — сообщения после +предыдущей успешно созданной отсечки. Markdown публикуется в теме `TELEGRAM_REPORT_THREAD_ID`, а в исходной теме остаётся отсечка со ссылкой и общим хэштегом отчёта. @@ -41,4 +43,4 @@ python3 -m unittest -v --- -Акылдаш v0.2.0 +Акылдаш v0.2.1 diff --git a/TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md b/TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md index e9359d0..50e9e93 100755 --- a/TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md +++ b/TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md @@ -398,4 +398,4 @@ Git сохраняет актуальную версию --- -Акылдаш v0.2.0 +Акылдаш v0.2.1 diff --git a/bot.py b/bot.py index 84233e8..36487c6 100755 --- a/bot.py +++ b/bot.py @@ -18,7 +18,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError APP_NAME = "Акылдаш" -APP_VERSION = "0.2.0" +APP_VERSION = "0.2.1" FOOTER = f"{APP_NAME} v{APP_VERSION}" @@ -146,6 +146,29 @@ def next_update_id(connection: sqlite3.Connection) -> int: return int(row["value"]) if row else 0 +def export_checkpoint( + connection: sqlite3.Connection, chat_id: int, thread_id: int +) -> int: + row = connection.execute( + "SELECT value FROM state WHERE key = ?", + (f"export_checkpoint:{chat_id}:{thread_id}",), + ).fetchone() + return int(row["value"]) if row else 0 + + +def save_export_checkpoint( + connection: sqlite3.Connection, chat_id: int, thread_id: int, message_id: int +) -> None: + with connection: + connection.execute( + """ + INSERT INTO state (key, value) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value + """, + (f"export_checkpoint:{chat_id}:{thread_id}", str(message_id)), + ) + + def telegram_request( config: Config, method: str, payload: dict, timeout: int = 65 ) -> object: @@ -248,10 +271,14 @@ def export_markdown( thread_id: int, days: int | None, now: int | None = None, + after_message_id: int | None = None, ) -> str: parameters: list[int] = [config.chat_id, thread_id] condition = "" - if days is not None: + if after_message_id is not None: + condition = " AND message_id > ?" + parameters.append(after_message_id) + elif days is not None: condition = " AND sent_at >= ?" parameters.append((now or int(time.time())) - days * 86400) rows = connection.execute( @@ -263,7 +290,14 @@ def export_markdown( parameters, ).fetchall() - period = "за всё время" if days is None else f"за последние {days} дн." + if after_message_id is not None: + period = ( + "с начала архива" + if after_message_id == 0 + else "после предыдущей отсечки" + ) + else: + period = "за всё время" if days is None else f"за последние {days} дн." lines = [ "# Обсуждение", "", @@ -328,8 +362,7 @@ def handle_command( message, "Я сохраняю обсуждения этой группы.\n" "/status — количество сохранённых сообщений\n" - "/export [дней] — экспорт текущей темы за 7 дней или указанный срок\n" - "/export все — экспорт текущей темы целиком", + "/export — экспорт текущей темы после предыдущей отсечки", ) elif command == "/status": topic_count = connection.execute( @@ -349,11 +382,22 @@ def handle_command( send_text(config, message, "Экспорт доступен только владельцу бота.") return try: - days = parse_export_days(parts) + days = parse_export_days(parts) if len(parts) > 1 else None except (ValueError, IndexError): send_text(config, message, "Использование: /export [1–3650|все]") return - markdown = export_markdown(connection, config, thread_id, days) + checkpoint = ( + None + if len(parts) > 1 + else export_checkpoint(connection, config.chat_id, thread_id) + ) + markdown = export_markdown( + connection, + config, + thread_id, + days, + after_message_id=checkpoint, + ) stamp = datetime.now(config.timezone).strftime("%Y%m%d-%H%M") report_tag = f"#report_{message['message_id']}" report = send_document( @@ -373,6 +417,9 @@ def handle_command( f"Отчёт: {message_link(config.chat_id, report['message_id'])}\n" "━━━━━━━━━━━━━━━━", ) + save_export_checkpoint( + connection, config.chat_id, thread_id, int(message["message_id"]) + ) def run() -> None: diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index dcf1898..73bb1fd 100755 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -3,7 +3,7 @@ Последняя проверка: 2026-08-03 Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов. -Версия приложения: `0.2.0` +Версия приложения: `0.2.1` ## Краткий итог @@ -52,10 +52,11 @@ Telegram-инфраструктура и первая версия бота-се ### Репозиторий и приложение -- Реализован бот-секретарь версии `0.2.0` без внешних Python-зависимостей. +- Реализован бот-секретарь версии `0.2.1` без внешних Python-зависимостей. - Сообщения и полные Telegram-метаданные сохраняются в SQLite. -- Добавлены команды `/help`, `/status`, `/export [дней]` и `/export все`. +- Добавлены команды `/help`, `/status` и `/export`. - Экспорт доступен только владельцу, указанному в `TELEGRAM_OWNER_ID`. +- Первый `/export` охватывает всю сохранённую тему, последующие начинаются после последней успешно созданной отсечки. - Отчёты публикуются в закрытой теме `Отчеты` (`message_thread_id=37`). - Исходное обсуждение завершается заметной отсечкой со ссылкой и хэштегом отчёта. - Локальный Git-репозиторий восстановлен и привязан к Gitea. @@ -119,6 +120,7 @@ Telegram-инфраструктура и первая версия бота-се ### 2026-08-03 +- Экспорт без параметров переведён с периода в семь дней на диапазон после предыдущей отсечки. - Бот развёрнут в Container Manager на Synology; автозапуск проверен перезапуском. - В теме `Работа с ИИ` опубликована первоначальная библиотека практик и отдельный материал о безопасной работе с Codex. - В `Общее` опубликовано и закреплено приветствие; тема закрыта для сообщений. @@ -149,4 +151,4 @@ Telegram-инфраструктура и первая версия бота-се --- -Акылдаш v0.2.0 +Акылдаш v0.2.1 diff --git a/docs/decisions/001-mvp-boundaries-and-rules.md b/docs/decisions/001-mvp-boundaries-and-rules.md index 0c5a654..f3a4f2e 100755 --- a/docs/decisions/001-mvp-boundaries-and-rules.md +++ b/docs/decisions/001-mvp-boundaries-and-rules.md @@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп --- -Акылдаш v0.2.0 +Акылдаш v0.2.1 diff --git a/docs/team/ai-skills-for-beginners.md b/docs/team/ai-skills-for-beginners.md index eb88411..96c4e49 100644 --- a/docs/team/ai-skills-for-beginners.md +++ b/docs/team/ai-skills-for-beginners.md @@ -180,4 +180,4 @@ --- -Акылдаш v0.2.0 +Акылдаш v0.2.1 diff --git a/test_bot.py b/test_bot.py index 3c7dca2..a8d9997 100644 --- a/test_bot.py +++ b/test_bot.py @@ -153,7 +153,7 @@ class SecretaryTest(unittest.TestCase): "date": 1, "chat": {"id": config.chat_id}, "from": {"id": config.owner_id}, - "text": "/export все", + "text": "/export", } with patch("bot.send_text") as send_text, patch( @@ -170,6 +170,69 @@ class SecretaryTest(unittest.TestCase): self.assertIn("#report_12", marker) self.assertIn("https://t.me/c/123/99", marker) + def test_next_export_starts_after_previous_cutoff(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) + + def archive(message_id, text): + archive_update( + connection, + { + "update_id": message_id, + "message": { + "message_id": message_id, + "message_thread_id": 2, + "date": message_id, + "chat": {"id": config.chat_id}, + "from": {"id": config.owner_id}, + "text": text, + }, + }, + config, + ) + + archive(10, "Первое обсуждение") + with patch("bot.send_text"), patch( + "bot.send_document", + side_effect=[{"message_id": 90}, {"message_id": 91}], + ) as send_document: + handle_command( + connection, + config, + { + "message_id": 12, + "message_thread_id": 2, + "from": {"id": config.owner_id}, + "text": "/export", + }, + ) + archive(13, "Второе обсуждение") + handle_command( + connection, + config, + { + "message_id": 14, + "message_thread_id": 2, + "from": {"id": config.owner_id}, + "text": "/export", + }, + ) + + first_export = send_document.call_args_list[0].args[3].decode() + second_export = send_document.call_args_list[1].args[3].decode() + self.assertIn("Первое обсуждение", first_export) + self.assertNotIn("Первое обсуждение", second_export) + self.assertIn("Второе обсуждение", second_export) + if __name__ == "__main__": unittest.main() -- 2.54.0