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.
126 lines
6 KiB
Python
126 lines
6 KiB
Python
"""Жизнь бота в чатах: добавили/исключили/поменяли права, служебные сообщения, вход и выход участников"""
|
||
import asyncio
|
||
import logging
|
||
|
||
from aiogram import Bot, F, Router
|
||
from aiogram.enums import ContentType
|
||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||
from aiogram.types import ChatMemberUpdated, Message
|
||
|
||
import db
|
||
|
||
router = Router()
|
||
|
||
WELCOME = ('<b>Привет группа {title}!</b>\n\nВсе функции вы можете узнать по команде /help! Для того, чтобы '
|
||
'уведомления по имени и команда /all нормально функционировали, необходимо чтобы каждый участник '
|
||
'группы написал хотя бы одно сообщение в чат! Также для того, чтобы системные сообщения удалялись '
|
||
'автоматически, необходимо боту выдать права администратора!')
|
||
|
||
|
||
def rights_of(member):
|
||
"""Права бота из ChatMember: все поля can_* (у админа — что он может, у ограниченного — что ему можно)"""
|
||
data = member.model_dump()
|
||
return {k: v for k, v in data.items() if k.startswith("can_") and isinstance(v, bool)} or None
|
||
|
||
|
||
# СТАТУС БОТА: событие Telegram при любом изменении (добавили, повысили, ограничили, исключили)
|
||
@router.my_chat_member()
|
||
async def bot_status_changed(update: ChatMemberUpdated, bot: Bot):
|
||
if update.chat.type == "private":
|
||
return
|
||
member = update.new_chat_member
|
||
count = None
|
||
if member.status in db.IN_CHAT:
|
||
try:
|
||
count = await bot.get_chat_member_count(update.chat.id)
|
||
except Exception:
|
||
pass
|
||
changed = await db.set_bot_status(update.chat.id, member.status, rights_of(member), "update",
|
||
member_count=count, chat_type=update.chat.type, title=update.chat.title)
|
||
if changed:
|
||
logging.info("Бот в чате %s (%s): %s", update.chat.id, update.chat.title, member.status)
|
||
|
||
# бота только что добавили — приветствие
|
||
old = update.old_chat_member.status
|
||
if member.status in ("member", "administrator") and old in ("left", "kicked"):
|
||
try:
|
||
await bot.send_message(update.chat.id, WELCOME.format(title=update.chat.title or ""))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def check_chat(bot: Bot, chat_id):
|
||
"""Сверка статуса бота с Telegram — ловит изменения, пока бот был выключен"""
|
||
try:
|
||
chat = await bot.get_chat(chat_id)
|
||
member = await bot.get_chat_member(chat_id, bot.id)
|
||
count = await bot.get_chat_member_count(chat_id) if member.status in db.IN_CHAT else None
|
||
await db.set_bot_status(chat_id, member.status, rights_of(member), "check",
|
||
member_count=count, chat_type=chat.type, title=chat.title)
|
||
except TelegramForbiddenError:
|
||
# исключён или чат удалён: данные не трогаем, фиксируем, что бота там нет
|
||
await db.set_bot_status(chat_id, "kicked", None, "check")
|
||
except TelegramBadRequest as e:
|
||
if "chat not found" in str(e).lower() or "upgraded to a supergroup" in str(e).lower():
|
||
await db.set_bot_status(chat_id, "left", None, "check")
|
||
else:
|
||
logging.warning("Сверка чата %s: %s", chat_id, e)
|
||
|
||
|
||
async def check_all(bot: Bot):
|
||
for chat_id in await db.chats_to_check():
|
||
await check_chat(bot, chat_id)
|
||
await asyncio.sleep(0.5) # без спешки — лимиты Telegram API
|
||
|
||
|
||
# НОВЫЕ УЧАСТНИКИ: служебное сообщение удаляется (если включено и есть права)
|
||
@router.message(F.content_type == ContentType.NEW_CHAT_MEMBERS)
|
||
async def new_members(message: Message):
|
||
await db.ensure_chat(message.chat.id, message.chat.title)
|
||
if any(u.id == message.bot.id for u in message.new_chat_members):
|
||
return # о самом боте — приветствие в bot_status_changed
|
||
if await db.get_setting(message.chat.id, "delete_service"):
|
||
try:
|
||
await message.delete()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ГРУППА СТАЛА СУПЕРГРУППОЙ — новый id чата
|
||
@router.message(F.content_type.in_([ContentType.MIGRATE_TO_CHAT_ID, ContentType.MIGRATE_FROM_CHAT_ID]))
|
||
async def chat_migrated(message: Message):
|
||
if message.migrate_to_chat_id:
|
||
await db.migrate_chat(message.chat.id, message.migrate_to_chat_id)
|
||
elif message.migrate_from_chat_id:
|
||
await db.migrate_chat(message.migrate_from_chat_id, message.chat.id)
|
||
|
||
|
||
# УЧАСТНИК ВЫШЕЛ: статистика остаётся, участник больше не упоминается
|
||
@router.message(F.content_type == ContentType.LEFT_CHAT_MEMBER)
|
||
async def left_member(message: Message):
|
||
if message.left_chat_member.id == message.bot.id:
|
||
return
|
||
await db.member_left(message.chat.id, message.left_chat_member.id)
|
||
if await db.get_setting(message.chat.id, "delete_service"):
|
||
try:
|
||
await message.delete()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ТЕХНИЧЕСКИЕ СООБЩЕНИЯ
|
||
@router.message(F.content_type.in_([
|
||
ContentType.NEW_CHAT_TITLE,
|
||
ContentType.NEW_CHAT_PHOTO,
|
||
ContentType.PINNED_MESSAGE,
|
||
ContentType.VIDEO_CHAT_ENDED,
|
||
ContentType.VIDEO_CHAT_PARTICIPANTS_INVITED
|
||
]))
|
||
async def chat_events(message: Message):
|
||
if message.new_chat_title:
|
||
await db.ensure_chat(message.chat.id, message.new_chat_title)
|
||
if await db.get_setting(message.chat.id, "delete_service"):
|
||
try:
|
||
await message.delete()
|
||
except Exception:
|
||
pass
|