miltu-bot refactor
This commit is contained in:
44
bot/admin.py
44
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("<a href='{0}' target='_blank'>{0}</a>", 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")
|
||||
@@ -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"
|
||||
|
||||
74
bot/bot.py
Normal file
74
bot/bot.py
Normal 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
56
bot/bot_factory.py
Normal 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
|
||||
104
bot/handlers.py
104
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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
160
bot/models.py
160
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}]"
|
||||
|
||||
52
bot/services.py
Normal file
52
bot/services.py
Normal 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())
|
||||
Reference in New Issue
Block a user