feat: export messages after last cutoff

This commit is contained in:
2026-08-03 00:49:35 +03:00
parent d4ee76948f
commit d9b4f19af3
7 changed files with 132 additions and 18 deletions

View File

@@ -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

View File

@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
---
Акылдаш v0.2.0
Акылдаш v0.2.1

61
bot.py
View File

@@ -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 [13650|все]")
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:

View File

@@ -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

View File

@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
---
Акылдаш v0.2.0
Акылдаш v0.2.1

View File

@@ -180,4 +180,4 @@
---
Акылдаш v0.2.0
Акылдаш v0.2.1

View File

@@ -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()