mirror of
https://github.com/EDeev/chatping_abobot.git
synced 2026-10-07 20:49:45 +03:00
База — PostgreSQL (asyncpg) вместо четырёх файлов SQLite с таблицей на каждый чат: chats, users, members, chat_stats и member_stats с полем «период» — месяцы хранятся, а не обнуляются; счётчики растут одним запросом без блокировки бота. scripts/migrate_sqlite.py переносит старые базы со сверкой сумм (проверено на копии рабочих баз: 20 694 пользователя, 45 чатов, 3 142 038 сообщений — совпадает). Учёт бота в чатах: статус и права бота (событие my_chat_member и сверка с Telegram при запуске и раз в 6 часов), история изменений в bot_status_history; из чатов, где бота исключили, данные не удаляются — меняется только статус. Большие чаты (больше 100 участников): /all — только для админов, раз в 5 минут и только писавшие за 30 дней; упоминание одного человека по имени — не чаще раза в минуту. В любом чате /all делится на сообщения до 4096 символов (раньше в чате на 14 тысяч падал). Новое: /settings (упоминания, имена в голосовых, ивенты, удаление служебных сообщений — меняют админы), /top, /month (итоги месяца по команде). /stop_bot раньше ничего не выключал — флаг не проверялся; теперь работает. Участник, вышедший из чата, больше не упоминается, но его статистика сохраняется. Ошибки — в технический чат DEBUG_CHAT_ID. Код: handlers.py разбит на роутеры, Markdown заменён на HTML (экранирование через html.escape), тесты на PostgreSQL (18), CI на Python 3.10 и 3.12.
119 lines
6.6 KiB
Python
119 lines
6.6 KiB
Python
"""Перенос данных AboBot из четырёх баз SQLite в PostgreSQL.
|
||
|
||
python scripts/migrate_sqlite.py --sqlite-dir /path/to/db --dsn postgresql://…
|
||
|
||
Старые базы: users.db (users, groups — внутренние номера ↔ id Telegram), base.db (work, stat, edit),
|
||
groups.db (таблица на каждый чат со статистикой участников), month.db (месячная статистика).
|
||
Месячная статистика не переносится: в старой версии она ни разу не обнулялась и фактически
|
||
дублировала статистику за всё время; новая копится по месяцам с момента переноса.
|
||
"""
|
||
import argparse
|
||
import asyncio
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))
|
||
|
||
import db # noqa: E402
|
||
|
||
FIELDS = db.STAT_FIELDS
|
||
|
||
|
||
async def migrate(sqlite_dir, dsn, force=False):
|
||
users_db = sqlite3.connect(os.path.join(sqlite_dir, "users.db"))
|
||
base_db = sqlite3.connect(os.path.join(sqlite_dir, "base.db"))
|
||
groups_db = sqlite3.connect(os.path.join(sqlite_dir, "groups.db"))
|
||
|
||
pool = await db.connect(dsn)
|
||
async with pool.acquire() as conn:
|
||
if await conn.fetchval("SELECT count(*) FROM chats"):
|
||
if not force:
|
||
raise SystemExit("В PostgreSQL уже есть данные — перенос остановлен (--force очистит таблицы)")
|
||
await conn.execute("TRUNCATE chats, users, members, chat_stats, member_stats, bot_status_history "
|
||
"RESTART IDENTITY CASCADE")
|
||
|
||
async with conn.transaction():
|
||
# пользователи и чаты с прежними внутренними номерами
|
||
users = {}
|
||
for short, tg in users_db.execute("SELECT id, user_id FROM users ORDER BY id"):
|
||
if tg not in users.values():
|
||
users[short] = tg
|
||
await conn.executemany("INSERT INTO users (id, short_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||
[(tg, short) for short, tg in users.items()])
|
||
|
||
states = dict(base_db.execute("SELECT group_id, state FROM work"))
|
||
chats = {}
|
||
for short, tg in users_db.execute('SELECT id, group_id FROM "groups" ORDER BY id'):
|
||
if tg not in chats.values():
|
||
chats[short] = tg
|
||
await conn.executemany(
|
||
"INSERT INTO chats (id, short_id, events_enabled) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||
[(tg, short, bool(states.get(short, True))) for short, tg in chats.items()])
|
||
for table in ("users", "chats"):
|
||
await conn.execute(f"SELECT setval(pg_get_serial_sequence('{table}', 'short_id'), "
|
||
f"GREATEST((SELECT max(short_id) FROM {table}), 1))")
|
||
|
||
# статистика чатов за всё время
|
||
chat_rows = []
|
||
for row in base_db.execute(f"SELECT group_id, {', '.join(FIELDS)} FROM stat"):
|
||
if row[0] in chats:
|
||
chat_rows.append((chats[row[0]], *[v or 0 for v in row[1:]]))
|
||
await conn.executemany(
|
||
f"INSERT INTO chat_stats (chat_id, period, {', '.join(FIELDS)}) "
|
||
f"VALUES ($1, 'all', {', '.join(f'${i}' for i in range(2, 9))}) ON CONFLICT DO NOTHING", chat_rows)
|
||
|
||
# участники и их статистика: таблица groups.db на каждый чат
|
||
custom = {r[0] for r in base_db.execute("SELECT user_id FROM edit")}
|
||
tables = [r[0] for r in groups_db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")]
|
||
members, stats, skipped = {}, {}, 0
|
||
for table in tables:
|
||
if not table.lstrip("-").isdigit() or int(table) not in chats:
|
||
continue
|
||
chat_id = chats[int(table)]
|
||
for row in groups_db.execute(f"SELECT user_id, first_name, {', '.join(FIELDS)} FROM [{table}]"):
|
||
# SQLite хранит тип по значению: имя «69» лежит числом
|
||
user_short, name = row[0], "" if row[1] is None else str(row[1])
|
||
if user_short not in users:
|
||
skipped += 1
|
||
continue
|
||
key = (chat_id, users[user_short])
|
||
members.setdefault(key, (name, user_short in custom))
|
||
old = stats.get(key, (0,) * len(FIELDS))
|
||
stats[key] = tuple(a + (b or 0) for a, b in zip(old, row[2:], strict=True))
|
||
await conn.executemany("INSERT INTO members (chat_id, user_id, name, custom_name) VALUES ($1, $2, $3, $4)",
|
||
[(c, u, n, cu) for (c, u), (n, cu) in members.items()])
|
||
await conn.executemany(
|
||
f"INSERT INTO member_stats (chat_id, user_id, period, {', '.join(FIELDS)}) "
|
||
f"VALUES ($1, $2, 'all', {', '.join(f'${i}' for i in range(3, 10))})",
|
||
[(c, u, *s) for (c, u), s in stats.items()])
|
||
|
||
report = {
|
||
"users": (len(users), await conn.fetchval("SELECT count(*) FROM users")),
|
||
"chats": (len(chats), await conn.fetchval("SELECT count(*) FROM chats")),
|
||
"members": (len(members), await conn.fetchval("SELECT count(*) FROM members")),
|
||
"chat messages": (sum(r[1] for r in chat_rows),
|
||
await conn.fetchval("SELECT coalesce(sum(mes), 0) FROM chat_stats WHERE period = 'all'")),
|
||
"member messages": (sum(s[0] for s in stats.values()),
|
||
await conn.fetchval("SELECT coalesce(sum(mes), 0) FROM member_stats WHERE period = 'all'")),
|
||
}
|
||
await db.close()
|
||
|
||
ok = all(a == b for a, b in report.values())
|
||
for key, (src, dst) in report.items():
|
||
print(f"{key:16} SQLite {src:>9} PostgreSQL {dst:>9} {'ok' if src == dst else 'MISMATCH'}")
|
||
print(f"участников без пользователя в users.db (пропущено): {skipped}")
|
||
return ok
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||
parser.add_argument("--sqlite-dir", required=True)
|
||
parser.add_argument("--dsn", default=os.getenv("DATABASE_URL"))
|
||
parser.add_argument("--force", action="store_true", help="очистить таблицы PostgreSQL перед переносом")
|
||
args = parser.parse_args()
|
||
sys.exit(0 if asyncio.run(migrate(args.sqlite_dir, args.dsn, args.force)) else 1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|