miltu-bot refactor

This commit is contained in:
2025-08-08 11:37:11 +09:00
parent 9750735089
commit ce28f29c69
12 changed files with 518 additions and 178 deletions

View File

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

View File

@@ -1,24 +1,50 @@
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html
from .models import TelegramBot, BotConfig, TelegramChat from .models import TelegramBot, BotConfig, TelegramChat
@admin.register(TelegramBot) @admin.register(TelegramBot)
class TelegramBotAdmin(admin.ModelAdmin): class TelegramBotAdmin(admin.ModelAdmin):
list_display = ("id", "name", "username", "is_active") list_display = ("name", "username", "is_active", "_has_config")
list_filter = ("is_active",) list_editable = ("is_active",)
search_fields = ("name", "username") search_fields = ("name", "username")
@admin.display(
description="Имеет конфиг",
boolean=True,
)
def _has_config(self, obj):
return bool(getattr(obj, "config", None))
@admin.register(BotConfig) @admin.register(BotConfig)
class BotConfigAdmin(admin.ModelAdmin): class BotConfigAdmin(admin.ModelAdmin):
list_display = ("id", "bot", "parse_mode", "use_webhook") list_display = ("bot", "parse_mode", "use_webhook", "_allowed_len", "_admins_len", "_webhook")
list_filter = ("use_webhook", "parse_mode") list_editable = ("use_webhook",)
autocomplete_fields = ("bot",) 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("<a href='{0}' target='_blank'>{0}</a>", obj.webhook_url)
return ""
@admin.register(TelegramChat) @admin.register(TelegramChat)
class TelegramChatAdmin(admin.ModelAdmin): class TelegramChatAdmin(admin.ModelAdmin):
list_display = ("id", "type", "title", "username", "is_member", "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 = ("type", "is_member") list_filter = ("bot", "type", "is_member")
search_fields = ("title", "username") search_fields = ("chat_id", "title", "username")
readonly_fields = ("joined_at", "left_at", "last_message_at") ordering = ("-is_member", "-joined_at")

View File

@@ -1,6 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
class BotConfigApp(AppConfig):
class BotConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField"
default_auto_field = 'django.db.models.BigAutoField' name = "bot"
name = 'bot' verbose_name = "Telegram Bot"

74
bot/bot.py Normal file
View File

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

56
bot/bot_factory.py Normal file
View File

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

View File

@@ -1,61 +1,61 @@
from datetime import datetime, timezone as py_tz import logging
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from django.utils import timezone
from .models import TelegramChat
def _now_aware(): logger = logging.getLogger(__name__)
return timezone.now()
async def upsert_chat_from_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: def _svc(context: ContextTypes.DEFAULT_TYPE):
""" return context.application.bot_data["services"] # BotServices
Вызывайте в message-хендлерах, чтобы обновлять last_message_at и тайтл.
"""
chat = update.effective_chat
if not chat:
return
obj, created = TelegramChat.objects.update_or_create( async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
id=chat.id, if update.effective_chat:
defaults={ s = _svc(context)
"type": chat.type, await s.chats.upsert_chat(
"title": chat.title or "", bot=s.bot,
"username": chat.username or "", chat_id=update.effective_chat.id,
"is_member": True, # если есть сообщения — бот, скорее всего, в чате chat_type=update.effective_chat.type,
"last_message_at": _now_aware(), title=getattr(update.effective_chat, "title", "") or "",
} username=getattr(update.effective_chat, "username", "") or "",
) is_member=True,
# created можно игнорировать; при необходимости — логировать 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: async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
""" if update.effective_chat:
Ловим изменение статуса бота в чатах (добавили/кикнули). s = _svc(context)
""" await s.chats.upsert_chat(
if not update.my_chat_member: bot=s.bot,
return 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 async def my_chat_member_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
new_status = update.my_chat_member.new_chat_member.status # "member"/"administrator"/"kicked"/"left"/... try:
is_in_chat = new_status in ("member", "administrator") 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 = { async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
"type": chat.type, logger.exception("PTB error: %s", context.error)
"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"])

View File

@@ -1,72 +1,60 @@
from django.core.management.base import BaseCommand, CommandError # bot/management/commands/runbots.py
from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, ChatMemberHandler, filters import asyncio
from django.db import transaction import logging
from bot.models import TelegramBot, BotConfig import signal
from bot.handlers import on_my_chat_member, upsert_chat_from_update 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): log = logging.getLogger(__name__)
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])
class Command(BaseCommand): class Command(BaseCommand):
help = "Запуск телеграм-бота (polling) на основе активного TelegramBot" help = "Запуск ВСЕХ активных ботов (PTB 22.3) в одном процессе."
def add_arguments(self, parser):
parser.add_argument("--bot-id", type=int, help="ID TelegramBot (если не указан — берём первый активный)")
def handle(self, *args, **options): 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: try:
if bot_id: # Инициализация и старт polling для каждого бота
bot = TelegramBot.objects.get(id=bot_id, is_active=True) for tb in bots:
else: app, allowed_updates = build_application_for_bot(tb)
bot = TelegramBot.objects.filter(is_active=True).first() await app.initialize()
if not bot: await app.start()
raise CommandError("Нет активного TelegramBot.") # в 22.x polling запускается через updater
except TelegramBot.DoesNotExist: await app.updater.start_polling(allowed_updates=allowed_updates)
raise CommandError("Указанный TelegramBot не найден или неактивен.") 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() def _stop(*_):
# Сохраним конфиг в bot_data, чтобы был доступ в хендлерах: stop_event.set()
app.bot_data["bot_conf"] = conf
# Команды loop = asyncio.get_running_loop()
app.add_handler(CommandHandler("start", cmd_start)) for sig in (signal.SIGINT, signal.SIGTERM):
app.add_handler(CommandHandler("groups", cmd_groups)) try:
loop.add_signal_handler(sig, _stop)
except NotImplementedError:
# Windows
pass
# Обновление списка чатов при изменении статуса бота await stop_event.wait()
app.add_handler(ChatMemberHandler(on_my_chat_member, ChatMemberHandler.MY_CHAT_MEMBER))
# Любые входящие сообщения — обновляем last_message_at для чатов finally:
app.add_handler(MessageHandler(filters.ALL, upsert_chat_from_update)) # Корректная остановка всех приложений
for app in reversed(apps):
# allowed_updates (если заданы в конфиге) try:
allowed_updates = conf.allowed_updates if conf and conf.allowed_updates else None await app.updater.stop()
await app.stop()
self.stdout.write(self.style.SUCCESS(f"Бот {bot} запущен (polling).")) await app.shutdown()
app.run_polling(allowed_updates=allowed_updates, drop_pending_updates=True) except Exception:
log.exception("Shutdown error")

View File

@@ -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.db.models.deletion
import django.utils.timezone 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')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Произвольное имя бота в проекте', max_length=100)), ('name', models.CharField(help_text='Произвольное имя бота в проекте', max_length=100)),
('username', models.CharField(blank=True, help_text='@username в Telegram', max_length=100)), ('username', models.CharField(blank=True, help_text='@username в Telegram', max_length=100)),
('token', models.CharField(max_length=200)), ('token', models.CharField(help_text='Токен от @BotFather', max_length=200)),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True, help_text='Если включен — может быть запущен runbots')),
],
),
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)),
], ],
options={ options={
'verbose_name': 'Telegram чат', 'verbose_name': 'Telegram бот',
'verbose_name_plural': 'Telegram чаты', 'verbose_name_plural': 'Telegram боты',
'ordering': ('-is_active', 'name'),
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='BotConfig', name='BotConfig',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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)), ('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 админов')), ('admin_user_ids', models.JSONField(blank=True, default=list, help_text='Список Telegram user_id, имеющих права администратора')),
('webhook_url', models.URLField(blank=True, default='', help_text='Если используете вебхуки')), ('webhook_url', models.URLField(blank=True, default='', help_text='URL вебхука (если используете вебхуки)')),
('use_webhook', models.BooleanField(default=False)), ('use_webhook', models.BooleanField(default=False, help_text='Включить режим webhook вместо polling')),
('bot', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='config', to='bot.telegrambot')), ('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')],
},
), ),
] ]

View File

@@ -1,36 +1,90 @@
# bot/models.py
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
class TelegramBot(models.Model): 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'})" return f"{self.name} ({self.username or 'no-username'})"
class BotConfig(models.Model): class BotConfig(models.Model):
bot = models.OneToOneField(TelegramBot, on_delete=models.CASCADE, related_name="config") """
parse_mode = models.CharField( Конфигурация для конкретного бота (парсинг, allowed_updates, админы, вебхук).
max_length=20, default="HTML", Делается OneToOne, но без опоры на обратный атрибут .config у TelegramBot.
choices=[("HTML", "HTML"), ("MarkdownV2", "MarkdownV2"), ("None", "None")] """
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}" return f"Config for {self.bot}"
class TelegramChat(models.Model): class TelegramChat(models.Model):
""" """
Храним все чаты, где бот состоит/состоял. Чаты храним ПО БОТУ. Один и тот же chat_id TG может встречаться у разных ботов,
chat_id в TG может быть до ~52 бит => BigInteger. поэтому уникальность задаётся на (bot, chat_id).
Важно: не используем поле с именем 'id' под chat_id Telegram, чтобы не ломать Django.
Стандартный PK (BigAutoField) оставляем как есть.
""" """
CHAT_TYPES = [ CHAT_TYPES = [
("private", "private"), ("private", "private"),
@@ -39,23 +93,71 @@ class TelegramChat(models.Model):
("channel", "channel"), ("channel", "channel"),
] ]
id = models.BigIntegerField(primary_key=True) # chat_id как PK bot = models.ForeignKey(
type = models.CharField(max_length=20, choices=CHAT_TYPES) TelegramBot,
title = models.CharField(max_length=255, blank=True, default="") on_delete=models.CASCADE,
username = models.CharField(max_length=255, blank=True, default="") 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) is_member = models.BooleanField(
joined_at = models.DateTimeField(default=timezone.now) default=True,
left_at = models.DateTimeField(null=True, blank=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) last_message_at = models.DateTimeField(
null=True,
def __str__(self): blank=True,
base = self.title or self.username or str(self.id) help_text="Когда последний раз видели сообщение в этом чате",
return f"{base} [{self.type}]" )
class Meta: class Meta:
verbose_name = "Telegram чат" verbose_name = "Telegram чат"
verbose_name_plural = "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}]"

52
bot/services.py Normal file
View File

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

Binary file not shown.

Binary file not shown.