Compare commits
4 Commits
codex/proj
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
| f4a376ff4b | |||
| ba96b3e633 | |||
| caaba36a0c | |||
| 2921dbc152 |
8
.env.example
Normal file
8
.env.example
Normal 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
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Local credentials and secrets
|
# Local credentials and secrets
|
||||||
credentials.txt
|
credentials.json
|
||||||
|
|
||||||
# Python/runtime artifacts (for the upcoming bot implementation)
|
# Python/runtime artifacts (for the upcoming bot implementation)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -10,3 +10,6 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Local bot archive
|
||||||
|
data/
|
||||||
|
|||||||
44
README.md
Normal file
44
README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Акылдаш — бот-секретарь
|
||||||
|
|
||||||
|
Версия: `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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Synology
|
||||||
|
|
||||||
|
Контейнер `akyldash-bot` работает на образе `python:3.11-slim` с политикой
|
||||||
|
перезапуска `unless-stopped`. Постоянные данные находятся в
|
||||||
|
`/volume1/docker/akyldash/data`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Акылдаш v0.2.0
|
||||||
411
bot.py
Executable file
411
bot.py
Executable 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 [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")
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
# Статус проекта
|
# Статус проекта
|
||||||
|
|
||||||
Последняя проверка: 2026-07-31
|
Последняя проверка: 2026-08-02
|
||||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||||
|
|
||||||
|
Версия приложения: `0.2.0`
|
||||||
|
|
||||||
## Краткий итог
|
## Краткий итог
|
||||||
|
|
||||||
Telegram-инфраструктура подготовлена для начала разработки бота. Супергруппа и бот существуют, бот добавлен в группу, Privacy Mode отключён, темы созданы и проверены через Telegram Bot API. Исходный код бота пока не создан.
|
Telegram-инфраструктура и первая версия бота-секретаря подготовлены. Бот сохраняет сообщения разрешённых тем в SQLite, переживает перезапуск и экспортирует обсуждения в Markdown.
|
||||||
|
|
||||||
Ближайшая цель — реализовать минимальный архиватор сообщений с сохранением метаданных и Markdown-экспортом обсуждений.
|
Ближайшая цель — проверить экспорт из постоянно работающего контейнера и настроить резервное копирование SQLite.
|
||||||
|
|
||||||
## Уже сделано
|
## Уже сделано
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ Telegram-инфраструктура подготовлена для начал
|
|||||||
| Решения | `4` | подтверждено тестовым сообщением |
|
| Решения | `4` | подтверждено тестовым сообщением |
|
||||||
| Обсуждение | `6` | подтверждено тестовым сообщением |
|
| Обсуждение | `6` | подтверждено тестовым сообщением |
|
||||||
| Работа с ИИ | `8` | подтверждено тестовым сообщением |
|
| Работа с ИИ | `8` | подтверждено тестовым сообщением |
|
||||||
|
| Отчеты | `37` | закрытая тема для Markdown-экспортов |
|
||||||
|
|
||||||
### Документация и правила
|
### Документация и правила
|
||||||
|
|
||||||
@@ -41,37 +44,43 @@ Telegram-инфраструктура подготовлена для начал
|
|||||||
|
|
||||||
### Доступы
|
### Доступы
|
||||||
|
|
||||||
- Локальные доступы находятся в `credentials.txt`.
|
- Локальные доступы находятся в `credentials.json`.
|
||||||
- `credentials.txt` добавлен в `.gitignore`.
|
- `credentials.json` добавлен в `.gitignore`.
|
||||||
- Секреты нельзя добавлять в Git, логи, экспорты или сообщения бота.
|
- Секреты нельзя добавлять в Git, логи, экспорты или сообщения бота.
|
||||||
|
|
||||||
## Пока не сделано
|
|
||||||
|
|
||||||
### Репозиторий и приложение
|
### Репозиторий и приложение
|
||||||
|
|
||||||
- Не создан исходный код бота.
|
- Реализован бот-секретарь версии `0.2.0` без внешних Python-зависимостей.
|
||||||
- Не определена и не зафиксирована структура Python-проекта.
|
- Сообщения и полные Telegram-метаданные сохраняются в SQLite.
|
||||||
- Локальный каталог `.git` пуст; локальный Git-репозиторий ещё нужно корректно инициализировать или привязать к удалённому репозиторию Gitea.
|
- Добавлены команды `/help`, `/status`, `/export [дней]` и `/export все`.
|
||||||
- Не настроены Docker-файлы для Synology.
|
- Экспорт доступен только владельцу, указанному в `TELEGRAM_OWNER_ID`.
|
||||||
|
- Отчёты публикуются в закрытой теме `Отчеты` (`message_thread_id=37`).
|
||||||
|
- Исходное обсуждение завершается заметной отсечкой со ссылкой и хэштегом отчёта.
|
||||||
|
- Локальный Git-репозиторий восстановлен и привязан к Gitea.
|
||||||
|
|
||||||
|
### Развёртывание
|
||||||
|
|
||||||
|
- Бот постоянно запущен на Synology в контейнере `akyldash-bot`.
|
||||||
|
- Используется официальный образ `python:3.11-slim` и long polling.
|
||||||
|
- Контейнер работает без root, с read-only root filesystem и политикой `unless-stopped`.
|
||||||
|
- Код, закрытый env-файл и SQLite хранятся в `/volume1/docker/akyldash`.
|
||||||
|
|
||||||
|
## Пока не сделано
|
||||||
|
|
||||||
- Не настроен CI/CD.
|
- Не настроен CI/CD.
|
||||||
|
|
||||||
### Хранилище
|
### Хранилище
|
||||||
|
|
||||||
- Не подключена база данных.
|
- PostgreSQL не подключён; первая версия использует SQLite.
|
||||||
- Не утверждена окончательная схема PostgreSQL.
|
- Не утверждена окончательная схема PostgreSQL.
|
||||||
- Не определены сроки хранения сообщений, вложений и экспортов.
|
- Не определены сроки хранения сообщений, вложений и экспортов.
|
||||||
- Не определён согласованный список пользователей с доступом к архиву.
|
|
||||||
|
|
||||||
### Функции бота
|
### Функции бота
|
||||||
|
|
||||||
- Приём и сохранение сообщений.
|
- Скачивание файлов вложений; сейчас сохраняются их Telegram-метаданные.
|
||||||
- Сохранение автора, даты, темы, ID сообщения и reply-связи.
|
- Ручная пометка отдельных сообщений для экспорта.
|
||||||
- Сохранение доступных вложений.
|
|
||||||
- Ручная пометка сообщений для экспорта.
|
|
||||||
- Экспорт темы или диапазона в Markdown.
|
|
||||||
- Формирование готовой инструкции для ручной загрузки в ChatGPT.
|
- Формирование готовой инструкции для ручной загрузки в ChatGPT.
|
||||||
- Возврат отчёта пользователя в Telegram.
|
- Возврат отчёта пользователя в Telegram.
|
||||||
- Команды `/help`, `/status` и команда экспорта.
|
|
||||||
|
|
||||||
### Группа и рабочий процесс
|
### Группа и рабочий процесс
|
||||||
|
|
||||||
@@ -82,32 +91,39 @@ Telegram-инфраструктура подготовлена для начал
|
|||||||
|
|
||||||
## Важные неизвестные
|
## Важные неизвестные
|
||||||
|
|
||||||
Перед реализацией постоянного развёртывания нужно получить или принять решения по следующим вопросам:
|
Для следующего этапа нужно получить или принять решения по следующим вопросам:
|
||||||
|
|
||||||
1. Адрес и параметры PostgreSQL для разработки и Synology.
|
1. Адрес и параметры PostgreSQL для разработки и Synology.
|
||||||
2. Имя и URL удалённого репозитория Gitea.
|
2. Правила хранения и удаления сообщений и вложений.
|
||||||
3. Список пользователей Telegram, которым разрешён экспорт и работа с архивом.
|
3. Нужен ли webhook после постоянного развёртывания; первая версия работает через long polling.
|
||||||
4. Правила хранения и удаления сообщений и вложений.
|
|
||||||
5. Способ запуска бота: long polling на старте или webhook после развёртывания.
|
|
||||||
6. Нужен ли SQLite для локальной разработки как временное хранилище до подключения PostgreSQL.
|
|
||||||
|
|
||||||
## Следующий этап
|
## Следующий этап
|
||||||
|
|
||||||
### Stage 2 — бот-архиватор
|
### Проверка постоянного запуска
|
||||||
|
|
||||||
Рекомендуемый порядок:
|
Рекомендуемый порядок:
|
||||||
|
|
||||||
1. Создать каркас Python-приложения и конфигурацию через переменные окружения.
|
1. Проверить `/status` и `/export 1` из контейнера Synology.
|
||||||
2. Добавить фильтр группы `-1004242041275` и разрешённых тем `2`, `4`, `6`, `8`.
|
2. Перезапустить контейнер и убедиться, что сообщения не дублируются.
|
||||||
3. Реализовать приём сообщений и сохранение метаданных.
|
3. Настроить резервное копирование `/volume1/docker/akyldash/data`.
|
||||||
4. Добавить минимальную схему хранилища.
|
|
||||||
5. Реализовать ручную отметку сообщений.
|
|
||||||
6. Реализовать Markdown-экспорт с контекстом, авторами, датами, reply-связями и вложениями.
|
|
||||||
7. Добавить команды справки и состояния.
|
|
||||||
8. Проверить работу на реальном обсуждении и зафиксировать результат в этом документе.
|
|
||||||
|
|
||||||
## История изменений статуса
|
## История изменений статуса
|
||||||
|
|
||||||
|
### 2026-08-02
|
||||||
|
|
||||||
|
- Экспорт ограничен Telegram ID владельца бота.
|
||||||
|
- Технические ответы на корневое сообщение темы исключены из Markdown.
|
||||||
|
- Экспорт перенесён в тему `Отчеты`, добавлены отсечки и навигационные хэштеги.
|
||||||
|
- Проверена живая публикация отчёта и отсечки через Telegram Bot API.
|
||||||
|
- Бот версии `0.2.0` развёрнут в Container Manager на Synology.
|
||||||
|
- Версия приложения обновлена до `0.2.0`.
|
||||||
|
|
||||||
|
### 2026-08-01
|
||||||
|
|
||||||
|
- Восстановлена связь локального каталога с репозиторием Gitea.
|
||||||
|
- Реализован бот-секретарь версии `0.1.0` с SQLite и Markdown-экспортом.
|
||||||
|
- Проверены токен `help_clerk_bot` и доступ к супергруппе `Акылдаш`.
|
||||||
|
|
||||||
### 2026-07-31
|
### 2026-07-31
|
||||||
|
|
||||||
- Создана супергруппа и добавлен бот.
|
- Создана супергруппа и добавлен бот.
|
||||||
@@ -115,4 +131,3 @@ Telegram-инфраструктура подготовлена для начал
|
|||||||
- Определены ID тем: `2`, `4`, `6`, `8`.
|
- Определены ID тем: `2`, `4`, `6`, `8`.
|
||||||
- Подтверждено отсутствие webhook.
|
- Подтверждено отсутствие webhook.
|
||||||
- Зафиксировано, что код бота и хранилище ещё не реализованы.
|
- Зафиксировано, что код бота и хранилище ещё не реализованы.
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Telegram не является хранилищем проекта, а бот и
|
|||||||
|
|
||||||
## Что входит в MVP
|
## Что входит в MVP
|
||||||
|
|
||||||
- Telegram-супергруппа с темами `Общее` (стандартная тема), `MVP`, `Решения`, `Обсуждение` и `Работа с ИИ`.
|
- Telegram-супергруппа с темами `Общее` (стандартная тема), `MVP`, `Решения`, `Обсуждение`, `Работа с ИИ` и `Отчеты`.
|
||||||
- Тема `MVP` используется только для обсуждения границ первой версии. После фиксации MVP тема закрывается.
|
- Тема `MVP` используется только для обсуждения границ первой версии. После фиксации MVP тема закрывается.
|
||||||
- Тема `Обсуждение` используется для рабочих гипотез и вопросов, которые ещё не стали подтверждёнными решениями.
|
- Тема `Обсуждение` используется для рабочих гипотез и вопросов, которые ещё не стали подтверждёнными решениями.
|
||||||
- В теме `Решения` бот публикует итоговые сводки и подтверждённые решения после завершения обсуждения. Сообщения бота являются единственным редактируемым источником итогового текста; пользователи не редактируют сообщения бота.
|
- В теме `Решения` бот публикует итоговые сводки и подтверждённые решения после завершения обсуждения. Сообщения бота являются единственным редактируемым источником итогового текста; пользователи не редактируют сообщения бота.
|
||||||
@@ -20,6 +20,7 @@ Telegram не является хранилищем проекта, а бот и
|
|||||||
- Для сообщения сохраняются текст, автор, дата и время, тема, идентификатор сообщения и связь с ответом.
|
- Для сообщения сохраняются текст, автор, дата и время, тема, идентификатор сообщения и связь с ответом.
|
||||||
- Экспорт выполняется по ручной отметке участника или по явно заданному диапазону темы.
|
- Экспорт выполняется по ручной отметке участника или по явно заданному диапазону темы.
|
||||||
- Экспорт формируется в Markdown и включает контекст, сообщения по времени, ссылки и доступные вложения.
|
- Экспорт формируется в Markdown и включает контекст, сообщения по времени, ссылки и доступные вложения.
|
||||||
|
- Markdown публикуется в закрытой для сообщений участников теме `Отчеты`. В исходной теме бот оставляет заметную отсечку, прямую ссылку и общий с отчётом уникальный хэштег.
|
||||||
- Участник вручную загружает экспорт в ChatGPT и возвращает результат в Telegram.
|
- Участник вручную загружает экспорт в ChatGPT и возвращает результат в Telegram.
|
||||||
- Gitea-уведомления на старте можно публиковать в `Общее` либо в отдельную тему после появления такой потребности. События ограничиваются PR, задачами, дедлайнами и сбоями важных проверок.
|
- Gitea-уведомления на старте можно публиковать в `Общее` либо в отдельную тему после появления такой потребности. События ограничиваются PR, задачами, дедлайнами и сбоями важных проверок.
|
||||||
- Подтверждённые решения и изменения документации фиксируются в Git/Gitea обычным review-процессом.
|
- Подтверждённые решения и изменения документации фиксируются в Git/Gitea обычным review-процессом.
|
||||||
@@ -46,6 +47,7 @@ Telegram не является хранилищем проекта, а бот и
|
|||||||
- Используются темы `MVP`, `Решения`, `Обсуждение` и `Работа с ИИ`; тема `Общее` остаётся стандартной.
|
- Используются темы `MVP`, `Решения`, `Обсуждение` и `Работа с ИИ`; тема `Общее` остаётся стандартной.
|
||||||
- `MVP` закрывается после фиксации состава первой версии.
|
- `MVP` закрывается после фиксации состава первой версии.
|
||||||
- Итог обсуждения формирует бот и публикует в `Решения`.
|
- Итог обсуждения формирует бот и публикует в `Решения`.
|
||||||
|
- Файлы экспортов публикуются в теме `Отчеты`; исходные темы не засоряются файлами.
|
||||||
- `Работа с ИИ` предназначена только для публикаций бота и будет содержать best practice, а не обычный FAQ.
|
- `Работа с ИИ` предназначена только для публикаций бота и будет содержать best practice, а не обычный FAQ.
|
||||||
- Остальные параметры первой версии из этого документа считаются утверждёнными, если команда не изменит их отдельным решением.
|
- Остальные параметры первой версии из этого документа считаются утверждёнными, если команда не изменит их отдельным решением.
|
||||||
|
|
||||||
@@ -63,10 +65,10 @@ Telegram позволяет запретить пользователям отп
|
|||||||
|
|
||||||
## Инфраструктурные предпосылки
|
## Инфраструктурные предпосылки
|
||||||
|
|
||||||
- Доступы для настройки находятся в `credentials.txt`; секреты из этого файла нельзя включать в Git, логи CI/CD, экспорт Telegram или сообщения бота.
|
- Доступы для настройки находятся в `credentials.json`; секреты из этого файла нельзя включать в Git, логи CI/CD, экспорт Telegram или сообщения бота.
|
||||||
- Для проекта можно создать открытый репозиторий в Gitea.
|
- Для проекта можно создать открытый репозиторий в Gitea.
|
||||||
- В Gitea необходимо настроить защиту `main`: прямые push запрещены, изменения проходят через pull request и проверки CI/CD.
|
- В Gitea необходимо настроить защиту `main`: прямые push запрещены, изменения проходят через pull request и проверки CI/CD.
|
||||||
- Развёртывание бота планируется на Docker-инфраструктуре Synology.
|
- Бот развёрнут в Container Manager на Synology с постоянным SQLite-томом и политикой автоперезапуска.
|
||||||
- PostgreSQL будет использоваться как хранилище бота; параметры доступа к базе будут предоставлены отдельно.
|
- PostgreSQL будет использоваться как хранилище бота; параметры доступа к базе будут предоставлены отдельно.
|
||||||
- Почта может использоваться как дополнительный канал доставки файлов и отчётов, но не как основное хранилище.
|
- Почта может использоваться как дополнительный канал доставки файлов и отчётов, но не как основное хранилище.
|
||||||
|
|
||||||
|
|||||||
175
test_bot.py
Normal file
175
test_bot.py
Normal 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()
|
||||||
Reference in New Issue
Block a user