Compare commits
1 Commits
927da228c8
...
posts
| Author | SHA1 | Date | |
|---|---|---|---|
| f1153a0bab |
21
bot/handlers/chats.py
Normal file
21
bot/handlers/chats.py
Normal file
@@ -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"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
0
posts/__init__.py
Normal file
0
posts/__init__.py
Normal file
74
posts/admin.py
Normal file
74
posts/admin.py
Normal file
@@ -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("<div style='max-width:400px; white-space:pre-wrap'>{}</div>", (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 = "Последняя ошибка"
|
||||||
6
posts/apps.py
Normal file
6
posts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PostsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'posts'
|
||||||
0
posts/migrations/__init__.py
Normal file
0
posts/migrations/__init__.py
Normal file
3
posts/models.py
Normal file
3
posts/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
27
posts/tasks.py
Normal file
27
posts/tasks.py
Normal file
@@ -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()
|
||||||
3
posts/tests.py
Normal file
3
posts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
170
posts/utils.py
Normal file
170
posts/utils.py
Normal file
@@ -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
|
||||||
3
posts/views.py
Normal file
3
posts/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
@@ -42,6 +42,8 @@ INSTALLED_APPS = [
|
|||||||
'utils',
|
'utils',
|
||||||
'webapp',
|
'webapp',
|
||||||
'scheduller',
|
'scheduller',
|
||||||
|
'posts',
|
||||||
|
'tg_autopost', # Main app
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
Reference in New Issue
Block a user