From ce28f29c69a544db57e940b86fd098244a02865d Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Fri, 8 Aug 2025 11:37:11 +0900 Subject: [PATCH] miltu-bot refactor --- backups/backup_2025-08-08_10-48-44.sql | 28 +++++ bot/admin.py | 44 +++++-- bot/apps.py | 8 +- bot/bot.py | 74 ++++++++++++ bot/bot_factory.py | 56 +++++++++ bot/handlers.py | 104 ++++++++-------- bot/management/commands/runbot.py | 108 ++++++++--------- bot/migrations/0001_initial.py | 62 ++++++---- bot/models.py | 160 ++++++++++++++++++++----- bot/services.py | 52 ++++++++ db_storage/mysql/aria_log.00000001 | Bin 4890624 -> 4890624 bytes db_storage/mysql/aria_log_control | Bin 52 -> 52 bytes 12 files changed, 518 insertions(+), 178 deletions(-) create mode 100644 backups/backup_2025-08-08_10-48-44.sql create mode 100644 bot/bot.py create mode 100644 bot/bot_factory.py create mode 100644 bot/services.py diff --git a/backups/backup_2025-08-08_10-48-44.sql b/backups/backup_2025-08-08_10-48-44.sql new file mode 100644 index 0000000..f237fc2 --- /dev/null +++ b/backups/backup_2025-08-08_10-48-44.sql @@ -0,0 +1,28 @@ +/*M!999999\- enable the sandbox mode */ +-- MariaDB dump 10.19-11.6.2-MariaDB, for debian-linux-gnu (x86_64) +-- +-- Host: 127.0.0.1 Database: tg_autopost +-- ------------------------------------------------------ +-- Server version 11.6.2-MariaDB-ubu2404-log + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*M!100616 SET @OLD_NOTE_VERBOSITY=@@NOTE_VERBOSITY, NOTE_VERBOSITY=0 */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */; + +-- Dump completed on 2025-08-08 10:48:47 diff --git a/bot/admin.py b/bot/admin.py index 8a56397..767e1fe 100644 --- a/bot/admin.py +++ b/bot/admin.py @@ -1,24 +1,50 @@ from django.contrib import admin +from django.utils.html import format_html from .models import TelegramBot, BotConfig, TelegramChat @admin.register(TelegramBot) class TelegramBotAdmin(admin.ModelAdmin): - list_display = ("id", "name", "username", "is_active") - list_filter = ("is_active",) + list_display = ("name", "username", "is_active", "_has_config") + list_editable = ("is_active",) search_fields = ("name", "username") + @admin.display( + description="Имеет конфиг", + boolean=True, + ) + def _has_config(self, obj): + return bool(getattr(obj, "config", None)) + @admin.register(BotConfig) class BotConfigAdmin(admin.ModelAdmin): - list_display = ("id", "bot", "parse_mode", "use_webhook") - list_filter = ("use_webhook", "parse_mode") - autocomplete_fields = ("bot",) + list_display = ("bot", "parse_mode", "use_webhook", "_allowed_len", "_admins_len", "_webhook") + list_editable = ("use_webhook",) + fieldsets = ( + (None, {"fields": ("bot", "parse_mode")}), + ("Обновления и доступ", {"fields": ("allowed_updates", "admin_user_ids")}), + ("Webhook", {"fields": ("use_webhook", "webhook_url")}), + ) + + @admin.display(description="allowed_updates") + def _allowed_len(self, obj): + return len(obj.allowed_updates or []) + + @admin.display(description="admins") + def _admins_len(self, obj): + return len(obj.admin_user_ids or []) + + @admin.display(description="webhook_url") + def _webhook(self, obj): + if obj.use_webhook and obj.webhook_url: + return format_html("{0}", obj.webhook_url) + return "—" @admin.register(TelegramChat) class TelegramChatAdmin(admin.ModelAdmin): - list_display = ("id", "type", "title", "username", "is_member", "joined_at", "left_at", "last_message_at") - list_filter = ("type", "is_member") - search_fields = ("title", "username") - readonly_fields = ("joined_at", "left_at", "last_message_at") + list_display = ("bot", "chat_id", "type", "title", "username", "is_member", "joined_at", "left_at", "last_message_at") + list_filter = ("bot", "type", "is_member") + search_fields = ("chat_id", "title", "username") + ordering = ("-is_member", "-joined_at") \ No newline at end of file diff --git a/bot/apps.py b/bot/apps.py index 1cd7ff2..c776ad6 100644 --- a/bot/apps.py +++ b/bot/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig - -class BotConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'bot' +class BotConfigApp(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bot" + verbose_name = "Telegram Bot" diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..1f1cb83 --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,74 @@ +# bot/bot.py +import logging +from typing import Optional, Tuple +from telegram.constants import ParseMode + +from telegram.ext import ( + Application, + ApplicationBuilder, + CommandHandler, + MessageHandler, + ChatMemberHandler, + filters, + Defaults +) + +from .models import TelegramBot, BotConfig +from .handlers import start, ping, echo, my_chat_member_update, error_handler + +logger = logging.getLogger(__name__) + + +def _resolve_parse_mode(pm: Optional[str]): + """ + Преобразуем строковое значение из BotConfig.parse_mode в объект ParseMode. + 'None' -> None (без парсинга), иначе HTML/MarkdownV2. + """ + if not pm or pm == "None": + return None + # Безопасно берём атрибут из ParseMode, по умолчанию HTML + return getattr(ParseMode, pm, ParseMode.HTML) + + +def _get_active_bot_and_config() -> Tuple[TelegramBot, BotConfig]: + """ + Возвращает (активный TelegramBot, его BotConfig). + Бросает понятные ошибки, если данных нет. + """ + bot = TelegramBot.objects.filter(is_active=True).first() + if not bot: + raise RuntimeError( + "Нет активного TelegramBot (is_active=True). Создайте запись TelegramBot и включите её." + ) + + cfg = BotConfig.objects.filter(bot=bot).first() + if not cfg: + raise RuntimeError( + f"Для бота '{bot}' не найден BotConfig. Создайте связанную запись BotConfig." + ) + + return bot, cfg + + +def build_application() -> Application: + tb, cfg = _get_active_bot_and_config() + + parse_mode = _resolve_parse_mode(cfg.parse_mode) + allowed_updates = cfg.allowed_updates or None + + defaults = Defaults(parse_mode=parse_mode) if parse_mode else None + + builder: ApplicationBuilder = Application.builder().token(tb.token) + if defaults: + builder = builder.defaults(defaults) + + app = builder.build() + + app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("ping", ping)) + app.add_handler(ChatMemberHandler(my_chat_member_update, ChatMemberHandler.MY_CHAT_MEMBER)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo)) + app.add_error_handler(error_handler) + + app.bot_data["allowed_updates"] = allowed_updates + return app diff --git a/bot/bot_factory.py b/bot/bot_factory.py new file mode 100644 index 0000000..9a022b8 --- /dev/null +++ b/bot/bot_factory.py @@ -0,0 +1,56 @@ +# bot/bot_factory.py +import logging +from typing import Optional, Tuple +from telegram.constants import ParseMode +from telegram.ext import ( + Application, + ApplicationBuilder, + CommandHandler, + MessageHandler, + ChatMemberHandler, + Defaults, + filters, +) +from .models import TelegramBot, BotConfig +from .handlers import start, ping, echo, my_chat_member_update, error_handler +from .services import build_services + +logger = logging.getLogger(__name__) + +def _resolve_parse_mode(pm: Optional[str]): + if not pm or pm == "None": + return None + return getattr(ParseMode, pm, ParseMode.HTML) + +def build_application_for_bot(tb: TelegramBot) -> Tuple[Application, Optional[list]]: + cfg = BotConfig.objects.filter(bot=tb).first() + if not cfg: + raise RuntimeError(f"BotConfig не найден для бота '{tb}'") + + parse_mode = _resolve_parse_mode(cfg.parse_mode) + defaults = Defaults(parse_mode=parse_mode) if parse_mode else None + allowed_updates = cfg.allowed_updates or None + + builder: ApplicationBuilder = Application.builder().token(tb.token) + if defaults: + builder = builder.defaults(defaults) + + app = builder.build() + + # зарегистрировать хендлеры + app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("ping", ping)) + app.add_handler(ChatMemberHandler(my_chat_member_update, ChatMemberHandler.MY_CHAT_MEMBER)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo)) + app.add_error_handler(error_handler) + + # инжект зависимостей для КОНКРЕТНОГО бота + services = build_services(tb) + app.bot_data["services"] = services + app.bot_data["allowed_updates"] = allowed_updates + + logger.info( + "Built app for bot=%s (@%s), parse_mode=%s, allowed_updates=%s", + tb.name, tb.username or "—", parse_mode, allowed_updates + ) + return app, allowed_updates diff --git a/bot/handlers.py b/bot/handlers.py index bafdf2f..d02abaf 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -1,61 +1,61 @@ -from datetime import datetime, timezone as py_tz +import logging from telegram import Update from telegram.ext import ContextTypes -from django.utils import timezone -from .models import TelegramChat -def _now_aware(): - return timezone.now() +logger = logging.getLogger(__name__) -async def upsert_chat_from_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """ - Вызывайте в message-хендлерах, чтобы обновлять last_message_at и тайтл. - """ - chat = update.effective_chat - if not chat: - return +def _svc(context: ContextTypes.DEFAULT_TYPE): + return context.application.bot_data["services"] # BotServices - obj, created = TelegramChat.objects.update_or_create( - id=chat.id, - defaults={ - "type": chat.type, - "title": chat.title or "", - "username": chat.username or "", - "is_member": True, # если есть сообщения — бот, скорее всего, в чате - "last_message_at": _now_aware(), - } - ) - # created можно игнорировать; при необходимости — логировать +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if update.effective_chat: + s = _svc(context) + await s.chats.upsert_chat( + bot=s.bot, + chat_id=update.effective_chat.id, + chat_type=update.effective_chat.type, + title=getattr(update.effective_chat, "title", "") or "", + username=getattr(update.effective_chat, "username", "") or "", + is_member=True, + touch_last_message=True, + ) + user = update.effective_user + await update.effective_message.reply_text(f"Привет, {user.first_name or 'друг'}! Я бот автопостинга.") +async def ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await update.effective_message.reply_text("pong") -async def on_my_chat_member(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """ - Ловим изменение статуса бота в чатах (добавили/кикнули). - """ - if not update.my_chat_member: - return +async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if update.effective_chat: + s = _svc(context) + await s.chats.upsert_chat( + bot=s.bot, + chat_id=update.effective_chat.id, + chat_type=update.effective_chat.type, + title=getattr(update.effective_chat, "title", "") or "", + username=getattr(update.effective_chat, "username", "") or "", + is_member=True, + touch_last_message=True, + ) + await update.effective_message.reply_text(update.effective_message.text) - chat = update.my_chat_member.chat - new_status = update.my_chat_member.new_chat_member.status # "member"/"administrator"/"kicked"/"left"/... - is_in_chat = new_status in ("member", "administrator") +async def my_chat_member_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + chat = update.effective_chat + status = update.my_chat_member.new_chat_member.status # administrator|member|left|kicked + is_member = status in ("administrator", "member") + s = _svc(context) + await s.chats.upsert_chat( + bot=s.bot, + chat_id=chat.id, + chat_type=chat.type, + title=getattr(chat, "title", "") or "", + username=getattr(chat, "username", "") or "", + is_member=is_member, + ) + logger.info("my_chat_member: %s in %s (%s)", status, chat.id, chat.type) + except Exception: + logger.exception("Failed to process my_chat_member update") - defaults = { - "type": chat.type, - "title": chat.title or "", - "username": getattr(chat, "username", "") or "", - "is_member": is_in_chat, - "last_message_at": _now_aware(), - } - - if is_in_chat: - defaults["left_at"] = None - defaults.setdefault("joined_at", _now_aware()) - - obj, _ = TelegramChat.objects.update_or_create( - id=chat.id, - defaults=defaults - ) - - if not is_in_chat and obj.left_at is None: - obj.left_at = _now_aware() - obj.save(update_fields=["is_member", "left_at"]) +async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: + logger.exception("PTB error: %s", context.error) \ No newline at end of file diff --git a/bot/management/commands/runbot.py b/bot/management/commands/runbot.py index 69e4a83..57008af 100644 --- a/bot/management/commands/runbot.py +++ b/bot/management/commands/runbot.py @@ -1,72 +1,60 @@ -from django.core.management.base import BaseCommand, CommandError -from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, ChatMemberHandler, filters -from django.db import transaction -from bot.models import TelegramBot, BotConfig -from bot.handlers import on_my_chat_member, upsert_chat_from_update +# bot/management/commands/runbots.py +import asyncio +import logging +import signal +from django.core.management.base import BaseCommand +from bot.models import TelegramBot +from bot.bot_factory import build_application_for_bot -async def cmd_start(update, context): - await upsert_chat_from_update(update, context) - await update.message.reply_text("Я на связи. Добавьте меня в группу — запомню чат автоматически.") - -async def cmd_groups(update, context): - """ - /groups — покажем список чатов, где бот сейчас состоит. - (Лучше ограничить доступ: например, только admin_user_ids из BotConfig) - """ - bot_conf = await context.application.bot_data.get("bot_conf") - admin_ids = bot_conf.admin_user_ids if bot_conf else [] - if update.effective_user and admin_ids and update.effective_user.id not in admin_ids: - return - - from bot.models import TelegramChat - qs = TelegramChat.objects.filter(is_member=True).exclude(type="private").order_by("type", "title") - if not qs.exists(): - await update.message.reply_text("Пока ни одной группы/канала не найдено.") - return - - lines = [] - for ch in qs: - label = ch.title or ch.username or ch.id - lines.append(f"• {label} [{ch.type}] — {ch.id}") - await update.message.reply_text("\n".join(lines)[:3900]) +log = logging.getLogger(__name__) class Command(BaseCommand): - help = "Запуск телеграм-бота (polling) на основе активного TelegramBot" - - def add_arguments(self, parser): - parser.add_argument("--bot-id", type=int, help="ID TelegramBot (если не указан — берём первый активный)") + help = "Запуск ВСЕХ активных ботов (PTB 22.3) в одном процессе." def handle(self, *args, **options): - bot_id = options.get("bot_id") + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + asyncio.run(self._amain()) + async def _amain(self): + bots = list(TelegramBot.objects.filter(is_active=True)) + if not bots: + self.stderr.write(self.style.ERROR("Нет активных ботов (is_active=True).")) + return + + apps = [] try: - if bot_id: - bot = TelegramBot.objects.get(id=bot_id, is_active=True) - else: - bot = TelegramBot.objects.filter(is_active=True).first() - if not bot: - raise CommandError("Нет активного TelegramBot.") - except TelegramBot.DoesNotExist: - raise CommandError("Указанный TelegramBot не найден или неактивен.") + # Инициализация и старт polling для каждого бота + for tb in bots: + app, allowed_updates = build_application_for_bot(tb) + await app.initialize() + await app.start() + # в 22.x polling запускается через updater + await app.updater.start_polling(allowed_updates=allowed_updates) + apps.append(app) + log.info("Bot started: %s (@%s)", tb.name, tb.username or "—") - conf = getattr(bot, "config", None) + # Ожидание сигнала остановки + stop_event = asyncio.Event() - app = ApplicationBuilder().token(bot.token).build() - # Сохраним конфиг в bot_data, чтобы был доступ в хендлерах: - app.bot_data["bot_conf"] = conf + def _stop(*_): + stop_event.set() - # Команды - app.add_handler(CommandHandler("start", cmd_start)) - app.add_handler(CommandHandler("groups", cmd_groups)) + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, _stop) + except NotImplementedError: + # Windows + pass - # Обновление списка чатов при изменении статуса бота - app.add_handler(ChatMemberHandler(on_my_chat_member, ChatMemberHandler.MY_CHAT_MEMBER)) + await stop_event.wait() - # Любые входящие сообщения — обновляем last_message_at для чатов - app.add_handler(MessageHandler(filters.ALL, upsert_chat_from_update)) - - # allowed_updates (если заданы в конфиге) - allowed_updates = conf.allowed_updates if conf and conf.allowed_updates else None - - self.stdout.write(self.style.SUCCESS(f"Бот {bot} запущен (polling).")) - app.run_polling(allowed_updates=allowed_updates, drop_pending_updates=True) + finally: + # Корректная остановка всех приложений + for app in reversed(apps): + try: + await app.updater.stop() + await app.stop() + await app.shutdown() + except Exception: + log.exception("Shutdown error") diff --git a/bot/migrations/0001_initial.py b/bot/migrations/0001_initial.py index b3dd7bc..f25cf5c 100644 --- a/bot/migrations/0001_initial.py +++ b/bot/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.5 on 2025-08-08 01:42 +# Generated by Django 5.2.5 on 2025-08-08 02:33 import django.db.models.deletion import django.utils.timezone @@ -19,37 +19,51 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='Произвольное имя бота в проекте', max_length=100)), ('username', models.CharField(blank=True, help_text='@username в Telegram', max_length=100)), - ('token', models.CharField(max_length=200)), - ('is_active', models.BooleanField(default=True)), - ], - ), - migrations.CreateModel( - name='TelegramChat', - fields=[ - ('id', models.BigIntegerField(primary_key=True, serialize=False)), - ('type', models.CharField(choices=[('private', 'private'), ('group', 'group'), ('supergroup', 'supergroup'), ('channel', 'channel')], max_length=20)), - ('title', models.CharField(blank=True, default='', max_length=255)), - ('username', models.CharField(blank=True, default='', max_length=255)), - ('is_member', models.BooleanField(default=True)), - ('joined_at', models.DateTimeField(default=django.utils.timezone.now)), - ('left_at', models.DateTimeField(blank=True, null=True)), - ('last_message_at', models.DateTimeField(blank=True, null=True)), + ('token', models.CharField(help_text='Токен от @BotFather', max_length=200)), + ('is_active', models.BooleanField(default=True, help_text='Если включен — может быть запущен runbots')), ], options={ - 'verbose_name': 'Telegram чат', - 'verbose_name_plural': 'Telegram чаты', + 'verbose_name': 'Telegram бот', + 'verbose_name_plural': 'Telegram боты', + 'ordering': ('-is_active', 'name'), }, ), migrations.CreateModel( name='BotConfig', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('parse_mode', models.CharField(choices=[('HTML', 'HTML'), ('MarkdownV2', 'MarkdownV2'), ('None', 'None')], default='HTML', max_length=20)), - ('allowed_updates', models.JSONField(blank=True, default=list)), - ('admin_user_ids', models.JSONField(blank=True, default=list, help_text='Список Telegram user_id админов')), - ('webhook_url', models.URLField(blank=True, default='', help_text='Если используете вебхуки')), - ('use_webhook', models.BooleanField(default=False)), - ('bot', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='config', to='bot.telegrambot')), + ('parse_mode', models.CharField(choices=[('HTML', 'HTML'), ('MarkdownV2', 'MarkdownV2'), ('None', 'None')], default='HTML', help_text='Режим парсинга сообщений (или None для обычного текста)', max_length=20)), + ('allowed_updates', models.JSONField(blank=True, default=list, help_text="Список типов апдейтов, например ['message','my_chat_member','chat_member']")), + ('admin_user_ids', models.JSONField(blank=True, default=list, help_text='Список Telegram user_id, имеющих права администратора')), + ('webhook_url', models.URLField(blank=True, default='', help_text='URL вебхука (если используете вебхуки)')), + ('use_webhook', models.BooleanField(default=False, help_text='Включить режим webhook вместо polling')), + ('bot', models.OneToOneField(help_text='Какому боту принадлежит конфигурация', on_delete=django.db.models.deletion.CASCADE, to='bot.telegrambot')), ], + options={ + 'verbose_name': 'Конфигурация бота', + 'verbose_name_plural': 'Конфигурации ботов', + }, + ), + migrations.CreateModel( + name='TelegramChat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('chat_id', models.BigIntegerField(help_text='Идентификатор чата в Telegram (может быть до ~52 бит)')), + ('type', models.CharField(choices=[('private', 'private'), ('group', 'group'), ('supergroup', 'supergroup'), ('channel', 'channel')], help_text='Тип чата', max_length=20)), + ('title', models.CharField(blank=True, default='', help_text='Название (для групп/каналов)', max_length=255)), + ('username', models.CharField(blank=True, default='', help_text='Username чата/канала (если есть)', max_length=255)), + ('is_member', models.BooleanField(default=True, help_text='Состоит ли бот в чате сейчас')), + ('joined_at', models.DateTimeField(default=django.utils.timezone.now, help_text='Когда бот был добавлен')), + ('left_at', models.DateTimeField(blank=True, help_text='Когда бот покинул чат', null=True)), + ('last_message_at', models.DateTimeField(blank=True, help_text='Когда последний раз видели сообщение в этом чате', null=True)), + ('bot', models.ForeignKey(help_text='К какому боту относится этот чат', on_delete=django.db.models.deletion.CASCADE, related_name='chats', to='bot.telegrambot')), + ], + options={ + 'verbose_name': 'Telegram чат', + 'verbose_name_plural': 'Telegram чаты', + 'ordering': ('-is_member', '-joined_at'), + 'indexes': [models.Index(fields=['bot', 'chat_id'], name='bot_telegra_bot_id_ac3d0d_idx'), models.Index(fields=['bot', 'type'], name='bot_telegra_bot_id_3a438c_idx'), models.Index(fields=['bot', 'is_member'], name='bot_telegra_bot_id_e25f2b_idx')], + 'constraints': [models.UniqueConstraint(fields=('bot', 'chat_id'), name='uniq_bot_chat')], + }, ), ] diff --git a/bot/models.py b/bot/models.py index ddfb859..f4c8987 100644 --- a/bot/models.py +++ b/bot/models.py @@ -1,36 +1,90 @@ +# bot/models.py from django.db import models from django.utils import timezone class TelegramBot(models.Model): - name = models.CharField(max_length=100, help_text="Произвольное имя бота в проекте") - username = models.CharField(max_length=100, blank=True, help_text="@username в Telegram") - token = models.CharField(max_length=200) - is_active = models.BooleanField(default=True) + """ + Карточка бота. Можно держать несколько активных одновременно. + """ + name = models.CharField( + max_length=100, + help_text="Произвольное имя бота в проекте", + ) + username = models.CharField( + max_length=100, + blank=True, + help_text="@username в Telegram", + ) + token = models.CharField( + max_length=200, + help_text="Токен от @BotFather", + ) + is_active = models.BooleanField( + default=True, + help_text="Если включен — может быть запущен runbots", + ) - def __str__(self): + class Meta: + verbose_name = "Telegram бот" + verbose_name_plural = "Telegram боты" + ordering = ("-is_active", "name") + + def __str__(self) -> str: return f"{self.name} ({self.username or 'no-username'})" class BotConfig(models.Model): - bot = models.OneToOneField(TelegramBot, on_delete=models.CASCADE, related_name="config") - parse_mode = models.CharField( - max_length=20, default="HTML", - choices=[("HTML", "HTML"), ("MarkdownV2", "MarkdownV2"), ("None", "None")] + """ + Конфигурация для конкретного бота (парсинг, allowed_updates, админы, вебхук). + Делается OneToOne, но без опоры на обратный атрибут .config у TelegramBot. + """ + bot = models.OneToOneField( + TelegramBot, + on_delete=models.CASCADE, + help_text="Какому боту принадлежит конфигурация", + ) + parse_mode = models.CharField( + max_length=20, + default="HTML", + choices=[("HTML", "HTML"), ("MarkdownV2", "MarkdownV2"), ("None", "None")], + help_text="Режим парсинга сообщений (или None для обычного текста)", + ) + allowed_updates = models.JSONField( + default=list, + blank=True, + help_text="Список типов апдейтов, например ['message','my_chat_member','chat_member']", + ) + admin_user_ids = models.JSONField( + default=list, + blank=True, + help_text="Список Telegram user_id, имеющих права администратора", + ) + webhook_url = models.URLField( + blank=True, + default="", + help_text="URL вебхука (если используете вебхуки)", + ) + use_webhook = models.BooleanField( + default=False, + help_text="Включить режим webhook вместо polling", ) - allowed_updates = models.JSONField(default=list, blank=True) - admin_user_ids = models.JSONField(default=list, blank=True, help_text="Список Telegram user_id админов") - webhook_url = models.URLField(blank=True, default="", help_text="Если используете вебхуки") - use_webhook = models.BooleanField(default=False) - def __str__(self): + class Meta: + verbose_name = "Конфигурация бота" + verbose_name_plural = "Конфигурации ботов" + + def __str__(self) -> str: return f"Config for {self.bot}" class TelegramChat(models.Model): """ - Храним все чаты, где бот состоит/состоял. - chat_id в TG может быть до ~52 бит => BigInteger. + Чаты храним ПО БОТУ. Один и тот же chat_id TG может встречаться у разных ботов, + поэтому уникальность задаётся на (bot, chat_id). + + Важно: не используем поле с именем 'id' под chat_id Telegram, чтобы не ломать Django. + Стандартный PK (BigAutoField) оставляем как есть. """ CHAT_TYPES = [ ("private", "private"), @@ -39,23 +93,71 @@ class TelegramChat(models.Model): ("channel", "channel"), ] - id = models.BigIntegerField(primary_key=True) # chat_id как PK - type = models.CharField(max_length=20, choices=CHAT_TYPES) - title = models.CharField(max_length=255, blank=True, default="") - username = models.CharField(max_length=255, blank=True, default="") + bot = models.ForeignKey( + TelegramBot, + on_delete=models.CASCADE, + related_name="chats", + help_text="К какому боту относится этот чат", + ) + chat_id = models.BigIntegerField( + help_text="Идентификатор чата в Telegram (может быть до ~52 бит)", + ) + type = models.CharField( + max_length=20, + choices=CHAT_TYPES, + help_text="Тип чата", + ) + title = models.CharField( + max_length=255, + blank=True, + default="", + help_text="Название (для групп/каналов)", + ) + username = models.CharField( + max_length=255, + blank=True, + default="", + help_text="Username чата/канала (если есть)", + ) # Статус участия бота - is_member = models.BooleanField(default=True) - joined_at = models.DateTimeField(default=timezone.now) - left_at = models.DateTimeField(null=True, blank=True) + is_member = models.BooleanField( + default=True, + help_text="Состоит ли бот в чате сейчас", + ) + joined_at = models.DateTimeField( + default=timezone.now, + help_text="Когда бот был добавлен", + ) + left_at = models.DateTimeField( + null=True, + blank=True, + help_text="Когда бот покинул чат", + ) - # Метаданные - last_message_at = models.DateTimeField(null=True, blank=True) - - def __str__(self): - base = self.title or self.username or str(self.id) - return f"{base} [{self.type}]" + # Метаданные активности + last_message_at = models.DateTimeField( + null=True, + blank=True, + help_text="Когда последний раз видели сообщение в этом чате", + ) class Meta: verbose_name = "Telegram чат" verbose_name_plural = "Telegram чаты" + constraints = [ + models.UniqueConstraint( + fields=["bot", "chat_id"], + name="uniq_bot_chat", + ), + ] + indexes = [ + models.Index(fields=["bot", "chat_id"]), + models.Index(fields=["bot", "type"]), + models.Index(fields=["bot", "is_member"]), + ] + ordering = ("-is_member", "-joined_at") + + def __str__(self) -> str: + base = self.title or self.username or str(self.chat_id) + return f"{base} [{self.type}]" diff --git a/bot/services.py b/bot/services.py new file mode 100644 index 0000000..4d5029c --- /dev/null +++ b/bot/services.py @@ -0,0 +1,52 @@ +# bot/services.py +import logging +from dataclasses import dataclass +from typing import Optional, Protocol, Tuple, Coroutine, Any, Awaitable +from django.utils import timezone +from asgiref.sync import sync_to_async +from .models import TelegramBot, TelegramChat + +logger = logging.getLogger(__name__) + +# Протокол (интерфейс) репозитория — удобно мокать в тестах +from typing import Coroutine + +class IChatRepository(Protocol): + async def upsert_chat( + self, *, bot: TelegramBot, chat_id: int, chat_type: str, + title: str = "", username: str = "", is_member: bool = True, + touch_last_message: bool = False, + ) -> Coroutine[Any, Any, Tuple[TelegramChat, bool]]: ... + +# Реализация на Django ORM +class DjangoChatRepository: + @sync_to_async + def upsert_chat( + self, *, bot: TelegramBot, chat_id: int, chat_type: str, + title: str = "", username: str = "", is_member: bool = True, + touch_last_message: bool = False, + ): + defaults = { + "type": chat_type, + "title": title or "", + "username": username or "", + "is_member": is_member, + "joined_at": timezone.now() if is_member else None, + "left_at": None if is_member else timezone.now(), + } + if touch_last_message: + defaults["last_message_at"] = timezone.now() + + obj, created = TelegramChat.objects.update_or_create( + bot=bot, chat_id=chat_id, defaults=defaults + ) + return obj, created + +@dataclass +class BotServices: + """Контейнер зависимостей для конкретного бота.""" + bot: TelegramBot + chats: IChatRepository + +def build_services(bot: TelegramBot) -> BotServices: + return BotServices(bot=bot, chats=DjangoChatRepository()) diff --git a/db_storage/mysql/aria_log.00000001 b/db_storage/mysql/aria_log.00000001 index 80f27ef78f487e3682f1a70713f9ab0e88cea0f3..5e45bf9af0f3d23f444e104e7fc6fef373079305 100644 GIT binary patch delta 410 zcmZ9^IZMKE9LMpmN2%GrOik^uO!MgQ$PVrD=X$ zED~mkWg@H)CB`ao5+q5n#yT5pk|slz99!hsW`|w&C{Sdd0}eUjm=a|wR5{_4GtRl- zk}IzJW~UhpizGxWBAuowB42;IyRE)B{2}?*S5@zI9jV&WHP%v|pLD%Zb)aiq)n{EF XRDIL+UDc0YZPy~MwplVBE1$+Ut@D)8 delta 168 zcmWl|H%=EK*pi0oFjP!$4;C&bMC^WE7xw^x>I!T!K0EV oWzQ*#vZ^X^06z|hF&?|Ak-UFM_7pYB)U4^Qqn$N&HU diff --git a/db_storage/mysql/aria_log_control b/db_storage/mysql/aria_log_control index 7a4c0cfa0e9cc17e6c068a33205d884a6f818870..96193dce99723a299775e4d924a87dd5e7743587 100644 GIT binary patch delta 27 fcmXppnII>&?vZ>bBLl