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 = [