miltu-bot refactor
This commit is contained in:
28
backups/backup_2025-08-08_10-48-44.sql
Normal file
28
backups/backup_2025-08-08_10-48-44.sql
Normal 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
|
||||||
44
bot/admin.py
44
bot/admin.py
@@ -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")
|
||||||
@@ -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
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 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"])
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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')],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
160
bot/models.py
160
bot/models.py
@@ -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
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())
|
||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user