From f1153a0bab351501f077fd4d770f8a902ba33ab2 Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Fri, 8 Aug 2025 12:21:25 +0900 Subject: [PATCH] posts devel --- bot/handlers/chats.py | 21 +++++ posts/__init__.py | 0 posts/admin.py | 74 +++++++++++++++ posts/apps.py | 6 ++ posts/migrations/__init__.py | 0 posts/models.py | 3 + posts/tasks.py | 27 ++++++ posts/tests.py | 3 + posts/utils.py | 170 +++++++++++++++++++++++++++++++++++ posts/views.py | 3 + tg_autopost/settings.py | 2 + 11 files changed, 309 insertions(+) create mode 100644 bot/handlers/chats.py create mode 100644 posts/__init__.py create mode 100644 posts/admin.py create mode 100644 posts/apps.py create mode 100644 posts/migrations/__init__.py create mode 100644 posts/models.py create mode 100644 posts/tasks.py create mode 100644 posts/tests.py create mode 100644 posts/utils.py create mode 100644 posts/views.py diff --git a/bot/handlers/chats.py b/bot/handlers/chats.py new file mode 100644 index 0000000..4bac47b --- /dev/null +++ b/bot/handlers/chats.py @@ -0,0 +1,21 @@ +# bot/handlers/chats.py +from telegram import Update +from telegram.ext import ContextTypes + +async def on_my_chat_member(update: Update, context: ContextTypes.DEFAULT_TYPE): + mcm = update.my_chat_member + if not mcm: return + chat = mcm.chat + # сохранить/обновить TgChat + from posts.models import TgChat + TgChat.objects.update_or_create( + chat_id=chat.id, + defaults=dict( + title=chat.title or "", + username=getattr(chat, "username", "") or "", + type=chat.type, + is_active=(mcm.new_chat_member.status in ("administrator","member")) + ) + ) + + diff --git a/posts/__init__.py b/posts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/posts/admin.py b/posts/admin.py new file mode 100644 index 0000000..885dc2f --- /dev/null +++ b/posts/admin.py @@ -0,0 +1,74 @@ +# posts/admin.py +from django.contrib import admin +from .models import TgChat, PostTemplate, MediaItem, Post, PostJob +from django.utils.html import format_html +from django.utils import timezone +from .utils import render_post_text, build_keyboard, send_post_to_chat + + +@admin.register(TgChat) +class TgChatAdmin(admin.ModelAdmin): + list_display = ("chat_id", "title", "username", "type", "is_active", "added_at") + list_filter = ("type", "is_active") + search_fields = ("chat_id", "title", "username") + + +@admin.register(PostTemplate) +class PostTemplateAdmin(admin.ModelAdmin): + list_display = ("name", "parse_mode") + search_fields = ("name", "text") + + +@admin.register(MediaItem) +class MediaItemAdmin(admin.ModelAdmin): + list_display = ("type", "file_id", "local_path", "order") + list_filter = ("type",) + search_fields = ("file_id", "local_path") + + +class MediaInline(admin.TabularInline): + model = Post.media.through + extra = 0 + verbose_name = "Медиа" + verbose_name_plural = "Медиа" + + +@admin.action(description="Опубликовать выбранные посты сейчас") +def publish_now(modeladmin, request, queryset): + for post in queryset: + for chat in post.target_chats.all(): + try: + text = render_post_text(post) + kb = build_keyboard(post.buttons) + send_post_to_chat(chat.chat_id, post, text, kb) + except Exception as e: + post.status = Post.FAILED + post.save(update_fields=["status"]) + modeladmin.message_user(request, f"Ошибка публикации поста {post.id} в чат {chat}: {e}", level="error") + post.status = Post.SENT + post.save(update_fields=["status"]) + + +@admin.register(Post) +class PostAdmin(admin.ModelAdmin): + list_display = ("id", "title", "status", "scheduled_at", "created_at", "updated_at", "preview_text") + list_filter = ("status", "scheduled_at", "created_at") + search_fields = ("title", "text") + inlines = [MediaInline] + actions = [publish_now] + filter_horizontal = ("target_chats",) + + def preview_text(self, obj): + return format_html("
{}
", (obj.text or "")[:200]) + preview_text.short_description = "Текст (превью)" + + +@admin.register(PostJob) +class PostJobAdmin(admin.ModelAdmin): + list_display = ("id", "post", "chat", "run_at", "status", "attempts", "max_attempts", "last_error_short") + list_filter = ("status", "run_at") + search_fields = ("post__title", "chat__title", "last_error") + + def last_error_short(self, obj): + return (obj.last_error or "")[:100] + last_error_short.short_description = "Последняя ошибка" diff --git a/posts/apps.py b/posts/apps.py new file mode 100644 index 0000000..b18ed0d --- /dev/null +++ b/posts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PostsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'posts' diff --git a/posts/migrations/__init__.py b/posts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/posts/models.py b/posts/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/posts/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/posts/tasks.py b/posts/tasks.py new file mode 100644 index 0000000..c5d8534 --- /dev/null +++ b/posts/tasks.py @@ -0,0 +1,27 @@ +# posts/tasks.py +from celery import shared_task +from django.utils import timezone +from .models import PostJob, Post +from .utils import render_post_text, build_keyboard, send_post_to_chat + +@shared_task(bind=True, rate_limit="25/m") # мягкий лимит +def run_post_jobs(self): + jobs = (PostJob.objects + .select_related("post","chat") + .filter(status=Post.QUEUED, run_at__lte=timezone.now())[:50]) + for job in jobs: + try: + text = render_post_text(job.post) + kb = build_keyboard(job.post.buttons) + send_post_to_chat(job.chat.chat_id, job.post, text, kb) # обёртка над PTB Bot + job.status = Post.SENT + job.save(update_fields=["status"]) + except Exception as e: + job.attempts += 1 + job.last_error = str(e)[:2000] + if job.attempts >= job.max_attempts: + job.status = Post.FAILED + else: + # экспоненциальный backoff + job.run_at = timezone.now() + timezone.timedelta(minutes=2 ** min(job.attempts,4)) + job.save() diff --git a/posts/tests.py b/posts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/posts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/posts/utils.py b/posts/utils.py new file mode 100644 index 0000000..7a56caf --- /dev/null +++ b/posts/utils.py @@ -0,0 +1,170 @@ +# posts/utils.py +import asyncio +import logging +from contextlib import ExitStack +from typing import Iterable, Optional, List + +from django.template import Template, Context +from telegram import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + InputMediaPhoto, + InputMediaVideo, + InputMediaDocument, + Bot, +) +from telegram.error import TelegramError + +# ⬇️ берём фабрику приложений и доступ к активному боту +from bot.bot_factory import ( + get_first_active_bot_with_config, + build_application_for_bot, +) + +logger = logging.getLogger(__name__) + +# Кэш Application по bot.id, чтобы не собирать каждый раз +_APP_CACHE = {} + + +def _get_or_build_application(): + """ + Возвращает PTB Application для первого активного бота. + Если в проекте несколько ботов — логично в моделях Post хранить FK на TelegramBot + и сделать аналогичную функцию _get_or_build_application_for(bot). + """ + tb, cfg = get_first_active_bot_with_config() + app = _APP_CACHE.get(tb.id) + if app is None: + app, _allowed = build_application_for_bot(tb, cfg) + _APP_CACHE[tb.id] = app + logger.info("Application создан и закеширован для бота id=%s @%s", tb.id, tb.username or "—") + return app + + +def _ensure_run(coro): + """ + Безопасно выполнить async-корутину из синхронного кода (Django view, Celery task). + Если цикл уже есть — используем его, иначе создаём свой. + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + # Мы уже внутри event loop (редко, но бывает) — запускаем как таску и ждём + return asyncio.run_coroutine_threadsafe(coro, loop).result() + else: + return asyncio.run(coro) + + +def render_post_text(post) -> str: + """ + Рендер текста поста: + - Если есть шаблон, подставляем variables через Django Template; + - Иначе берём post.text. + """ + if getattr(post, "template", None): + try: + tpl = Template(post.template.text) + return tpl.render(Context(post.variables or {})) + except Exception as e: + logger.exception("Ошибка рендера шаблона поста #%s: %s", post.pk, e) + return post.text or "" + return post.text or "" + + +def build_keyboard(buttons) -> Optional[InlineKeyboardMarkup]: + """ + Ожидает JSON-структуру: + [ + [ {"text":"Купить","url":"https://..."}, {"text":"Подробнее","callback_data":"more"} ], + [ {"text":"Связаться","callback_data":"contact"} ] + ] + """ + if not buttons: + return None + try: + rows = [[InlineKeyboardButton(**btn) for btn in row] for row in buttons] + return InlineKeyboardMarkup(rows) + except Exception as e: + logger.exception("Ошибка построения клавиатуры: %s", e) + return None + + +async def _send_async(bot: Bot, chat_id: int, post, text: str, kb: Optional[InlineKeyboardMarkup]): + """ + Асинхронная отправка поста: + - media_group если >1, + - одиночное медиа, + - либо текст. + Важно: аккуратно закрываем открытые файловые дескрипторы. + """ + media_items: List = list(post.media.all()) if hasattr(post, "media") else [] + parse_mode = None if getattr(post, "parse_mode", None) in (None, "None") else post.parse_mode + + # Откроем все локальные файлы в одном стеке, чтобы гарантировать закрытие + with ExitStack() as stack: + def _payload(m): + # либо file_id, либо открываем local_path + if getattr(m, "file_id", None): + return m.file_id + if getattr(m, "local_path", None): + return stack.enter_context(open(m.local_path, "rb")) + raise ValueError("MediaItem без file_id и local_path") + + if len(media_items) > 1: + group = [] + for i, m in enumerate(media_items): + caption = text if i == 0 else None + if m.type == "photo": + group.append(InputMediaPhoto(media=_payload(m), caption=caption, parse_mode=parse_mode)) + elif m.type == "video": + group.append(InputMediaVideo(media=_payload(m), caption=caption, parse_mode=parse_mode)) + elif m.type == "document": + group.append(InputMediaDocument(media=_payload(m), caption=caption, parse_mode=parse_mode)) + else: + raise ValueError(f"Неизвестный тип медиа: {m.type}") + + await bot.send_media_group(chat_id=chat_id, media=group) + + # В media_group нельзя прикрепить inline-кнопки — отправим пустое техсообщение с клавиатурой + if kb: + await bot.send_message(chat_id=chat_id, text=" ", reply_markup=kb) + + elif len(media_items) == 1: + m = media_items[0] + if m.type == "photo": + await bot.send_photo(chat_id=chat_id, photo=_payload(m), caption=text or None, + parse_mode=parse_mode, reply_markup=kb) + elif m.type == "video": + await bot.send_video(chat_id=chat_id, video=_payload(m), caption=text or None, + parse_mode=parse_mode, reply_markup=kb) + elif m.type == "document": + await bot.send_document(chat_id=chat_id, document=_payload(m), caption=text or None, + parse_mode=parse_mode, reply_markup=kb) + else: + raise ValueError(f"Неизвестный тип медиа: {m.type}") + + else: + await bot.send_message(chat_id=chat_id, text=text, parse_mode=parse_mode, reply_markup=kb) + + +def send_post_to_chat(chat_id: int, post, text: str, kb: Optional[InlineKeyboardMarkup] = None): + """ + Синхронная обёртка для отправки поста (удобно звать из Celery/Django). + Внутри строим/берём Application у активного бота через bot_factory + и выполняем асинхронную отправку. + """ + app = _get_or_build_application() + bot = app.bot # Bot объект PTB v20 + try: + _ensure_run(_send_async(bot, chat_id, post, text, kb)) + logger.info("Пост #%s отправлен в чат %s", post.pk, chat_id) + except TelegramError as e: + logger.exception("Ошибка отправки поста #%s в чат %s: %s", post.pk, chat_id, e) + raise + except Exception as e: + logger.exception("Непредвиденная ошибка при отправке поста #%s: %s", post.pk, e) + raise diff --git a/posts/views.py b/posts/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/posts/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/tg_autopost/settings.py b/tg_autopost/settings.py index 09c5e19..c6e1862 100644 --- a/tg_autopost/settings.py +++ b/tg_autopost/settings.py @@ -42,6 +42,8 @@ INSTALLED_APPS = [ 'utils', 'webapp', 'scheduller', + 'posts', + 'tg_autopost', # Main app ] MIDDLEWARE = [