feat: add Telegram secretary bot

This commit is contained in:
2026-08-02 23:17:42 +03:00
parent 2921dbc152
commit caaba36a0c
7 changed files with 680 additions and 32 deletions

8
.env.example Normal file
View File

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

3
.gitignore vendored
View File

@@ -10,3 +10,6 @@ __pycache__/
.env
.env.*
!.env.example
# Local bot archive
data/

38
README.md Normal file
View File

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

411
bot.py Executable file
View File

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

View File

@@ -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.
- Зафиксировано, что код бота и хранилище ещё не реализованы.

View File

@@ -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.
- Остальные параметры первой версии из этого документа считаются утверждёнными, если команда не изменит их отдельным решением.

175
test_bot.py Normal file
View File

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