From fc06ec4a12dba527059f8a16a9d7dbf437b0046b Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 5 Apr 2026 15:52:47 +0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20v1.3=20-=20Scheduled=20Posts:=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Новая страница /scheduled_posts в веб-панели - Создание постов с фото/текстом в любую тему форума - Планирование даты и времени публикации - Автоматическая отправка по расписанию (каждую минуту) - Поддержка всех 10 тем форума - Уведомления админа о публикации/ошибках - Миграция БД: таблица scheduled_posts - Тесты: test_scheduled_posts.py Co-authored-by: Qwen-Coder --- database/migrate_add_scheduled_posts.py | 48 ++++ database/models.py | 60 +++++ main.py | 1 + services/scheduler.py | 164 ++++++++++++++ test_direct_send.py | 51 +++++ test_scheduled_posts.py | 83 +++++++ web/app.py | 173 +++++++++++++- web/templates/scheduled_posts.html | 288 ++++++++++++++++++++++++ 8 files changed, 867 insertions(+), 1 deletion(-) create mode 100644 database/migrate_add_scheduled_posts.py create mode 100644 test_direct_send.py create mode 100644 test_scheduled_posts.py create mode 100644 web/templates/scheduled_posts.html diff --git a/database/migrate_add_scheduled_posts.py b/database/migrate_add_scheduled_posts.py new file mode 100644 index 0000000..c2bd1e9 --- /dev/null +++ b/database/migrate_add_scheduled_posts.py @@ -0,0 +1,48 @@ +""" +Миграция: Добавить таблицу scheduled_posts для отложенной публикации +""" +import sqlite3 + +DB_PATH = 'database/domovoy.db' + + +def migrate(): + """Создать таблицу scheduled_posts""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Проверяем таблицу + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='scheduled_posts'") + if not cursor.fetchone(): + print("➕ Создаём таблицу scheduled_posts...") + cursor.execute(""" + CREATE TABLE scheduled_posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + photo_file_id TEXT, + topic_id BIGINT, + topic_name TEXT, + recipients TEXT DEFAULT 'chat_only', + scheduled_time TIMESTAMP NOT NULL, + status TEXT DEFAULT 'pending', + created_by BIGINT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + sent_at TIMESTAMP, + error_message TEXT, + message_id BIGINT, + FOREIGN KEY (created_by) REFERENCES users(user_id) + ) + """) + cursor.execute("CREATE INDEX ix_scheduled_posts_time ON scheduled_posts(scheduled_time)") + cursor.execute("CREATE INDEX ix_scheduled_posts_status ON scheduled_posts(status)") + cursor.execute("CREATE INDEX ix_scheduled_posts_topic ON scheduled_posts(topic_name)") + conn.commit() + print("✅ Таблица scheduled_posts создана") + else: + print("ℹ️ Таблица scheduled_posts уже существует") + + conn.close() + + +if __name__ == '__main__': + migrate() diff --git a/database/models.py b/database/models.py index a8d3a95..c72717f 100644 --- a/database/models.py +++ b/database/models.py @@ -595,3 +595,63 @@ class InitiativeGroup(Base): def __repr__(self): return f"" + + +class ScheduledPost(Base): + """Запланированные посты для отложенленной публикации""" + __tablename__ = 'scheduled_posts' + + id = Column(Integer, primary_key=True, autoincrement=True) + text = Column(Text, nullable=False) # Текст поста + photo_file_id = Column(String(255), nullable=True) # ID фото в Telegram + topic_id = Column(BigInteger, nullable=True) # ID темы форума (message_thread_id) + topic_name = Column(String(50), nullable=True) # Название темы ('general', 'memes'...) + recipients = Column(String(50), default='chat_only') # Получатели: 'chat_only', 'all_verified', 'all_and_chat' + scheduled_time = Column(DateTime, nullable=False) # Когда отправить + status = Column(String(20), default='pending') # 'pending', 'sent', 'failed', 'cancelled' + created_by = Column(BigInteger, nullable=True) # ID админа создавшего пост + created_at = Column(DateTime, default=datetime.utcnow) + sent_at = Column(DateTime, nullable=True) # Когда фактически отправлено + error_message = Column(Text, nullable=True) # Текст ошибки если failed + message_id = Column(BigInteger, nullable=True) # ID отправленного сообщения в Telegram + + __table_args__ = ( + Index('ix_scheduled_posts_time', 'scheduled_time'), + Index('ix_scheduled_posts_status', 'status'), + Index('ix_scheduled_posts_topic', 'topic_name'), + ) + + def __repr__(self): + return f"" + + @property + def is_due(self) -> bool: + """Пора ли отправлять пост""" + from datetime import datetime + return datetime.utcnow() >= self.scheduled_time and self.status == 'pending' + + def get_topic_emoji(self) -> str: + """Получить emoji темы""" + emojis = { + 'general': '💬', + 'ads': '📢', + 'memes': '😂', + 'meetings': '🏛️', + 'kgm': '🗑️', + 'lostfound': '🔍', + 'auto': '🚗', + 'phones': '📞', + 'links': '🔗', + 'uo': '📝', + } + return emojis.get(self.topic_name, '📝') + + def get_status_emoji(self) -> str: + """Получить emoji статуса""" + emojis = { + 'pending': '⏳', + 'sent': '✅', + 'failed': '❌', + 'cancelled': '🚫', + } + return emojis.get(self.status, '❓') diff --git a/main.py b/main.py index f77536c..0a2544b 100644 --- a/main.py +++ b/main.py @@ -163,6 +163,7 @@ from database.db import init_db from handlers import admin_router, users_router, verification_router, chat_monitor_router, security_router, apartments_router, antimat_router, phones_router, polls_router, antispam_router, ratelimit_router, schedule_router, ads_router, payments_router, multicount_router, topics_router, toxicity_router, profile_history_router, achievements_router, thanks_router, events_router, initiative_group_router from services.scheduler import Scheduler from services.web_server import WebServerService +from database.models import ScheduledPost # Для новой модели # Настройка логирования logging.basicConfig( diff --git a/services/scheduler.py b/services/scheduler.py index 74194f8..65b09c0 100644 --- a/services/scheduler.py +++ b/services/scheduler.py @@ -100,6 +100,16 @@ class Scheduler: self.scheduler.start() logger.info('✅ Планировщик запущен') + + # Добавляем задачу проверки запланированных постов (каждую минуту) + self.scheduler.add_job( + self.process_scheduled_posts, + CronTrigger.from_crontab('* * * * *'), # Каждую минуту + id='scheduled_posts_checker', + name='Проверка запланированных постов', + replace_existing=True + ) + logger.info('✅ Добавлена задача проверки запланированных постов') def stop(self): """Остановка планировщика""" @@ -281,3 +291,157 @@ class Scheduler: logger.error(f'Не удалось отправить напоминание о событии: {e}') except Exception as e: logger.error(f'Ошибка напоминаний о событиях: {e}') + + async def process_scheduled_posts(self): + """Проверка и отправка запланированных постов""" + try: + from database.db import AsyncSessionLocal + from database.models import ScheduledPost, User + from config import ADMIN_CHAT_ID, get_topic_id + from sqlalchemy import update + import aiohttp + + async with AsyncSessionLocal() as session: + # Ищем посты которые пора отправить + stmt = ( + select(ScheduledPost) + .where(ScheduledPost.status == 'pending') + .where(ScheduledPost.scheduled_time <= datetime.utcnow()) + ) + result = await session.execute(stmt) + posts_to_send = list(result.scalars().all()) + + if not posts_to_send: + return + + logger.info(f"📅 Найдено {len(posts_to_send)} постов для отправки") + + for post in posts_to_send: + try: + # Отправляем пост + message_id = None + bot_token = config.BOT_TOKEN + base_url = f"https://api.telegram.org/bot{bot_token}" + + # Настройка прокси для aiohttp + proxy_url = config.get_proxy_url() + connector = None + if proxy_url: + # Для socks прокси используем special connector + if proxy_url.startswith('socks'): + from aiohttp_socks import ProxyConnector + connector = ProxyConnector.from_url(proxy_url) + else: + # HTTP прокси + connector = aiohttp.TCPConnector() + + if post.photo_file_id: + # С фото - отправляем по file_id + url = f"{base_url}/sendPhoto" + params = { + 'chat_id': ADMIN_CHAT_ID, + 'photo': post.photo_file_id, + 'caption': post.text, + 'parse_mode': 'HTML' + } + if post.topic_id: + params['message_thread_id'] = post.topic_id + + async with aiohttp.ClientSession(connector=connector) as http_session: + async with http_session.post(url, json=params, timeout=30) as resp: + if resp.status == 200: + result_data = await resp.json() + message_id = result_data['result']['message_id'] + else: + error_text = await resp.text() + raise Exception(f"Telegram API {resp.status}: {error_text}") + else: + # Только текст + url = f"{base_url}/sendMessage" + params = { + 'chat_id': ADMIN_CHAT_ID, + 'text': post.text, + 'parse_mode': 'HTML' + } + if post.topic_id: + params['message_thread_id'] = post.topic_id + + async with aiohttp.ClientSession(connector=connector) as http_session: + async with http_session.post(url, json=params, timeout=30) as resp: + if resp.status == 200: + result_data = await resp.json() + message_id = result_data['result']['message_id'] + else: + error_text = await resp.text() + raise Exception(f"Telegram API {resp.status}: {error_text}") + + # Если нужно отправить в личку пользователям + if post.recipients in ['all_verified', 'all_and_chat']: + stmt_users = select(User).where(User.verified == True) + result_users = await session.execute(stmt_users) + users = list(result_users.scalars().all()) + + for user in users: + try: + if post.photo_file_id: + url = f"{base_url}/sendPhoto" + params = { + 'chat_id': user.user_id, + 'photo': post.photo_file_id, + 'caption': f"📢 Объявление от администрации\n\n{post.text}", + 'parse_mode': 'HTML' + } + else: + url = f"{base_url}/sendMessage" + params = { + 'chat_id': user.user_id, + 'text': f"📢 Объявление от администрации\n\n{post.text}", + 'parse_mode': 'HTML' + } + + async with aiohttp.ClientSession(connector=connector) as http_session: + async with http_session.post(url, json=params, timeout=10) as resp: + if resp.status != 200: + logger.error(f"Не удалось отправить пользователю {user.user_id}") + except Exception as e: + logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}") + + # Обновляем статус поста + post.status = 'sent' + post.sent_at = datetime.utcnow() + post.message_id = message_id + await session.commit() + + logger.info(f"✅ Пост отправлен: id={post.id}, тема={post.topic_name}") + + # Уведомляем админа + await self.bot.send_message( + config.ADMIN_USER_ID, + f'✅ Пост опубликован!\n\n' + f'ID: {post.id}\n' + f'Тема: {post.get_topic_emoji()} {post.topic_name}\n' + f'Время: {post.scheduled_time.strftime("%d.%m.%Y %H:%M")}\n' + f'Статус: {post.get_status_emoji()} {post.status}', + parse_mode='HTML' + ) + + except Exception as e: + # Ошибка при отправке + post.status = 'failed' + post.error_message = str(e)[:500] + await session.commit() + + logger.error(f"❌ Ошибка отправки поста {post.id}: {e}") + + # Уведомляем админа об ошибке + await self.bot.send_message( + config.ADMIN_USER_ID, + f'❌ Ошибка публикации поста!\n\n' + f'ID: {post.id}\n' + f'Тема: {post.topic_name}\n' + f'Ошибка: {str(e)[:200]}', + parse_mode='HTML' + ) + + except Exception as e: + logger.error(f'Ошибка обработки запланированных постов: {e}') diff --git a/test_direct_send.py b/test_direct_send.py new file mode 100644 index 0000000..eca2119 --- /dev/null +++ b/test_direct_send.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Прямой тест отправки в тему +""" +import asyncio +import aiohttp +import sys +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(BASE_DIR)) + +from config import BOT_TOKEN, ADMIN_CHAT_ID + +async def test_send_to_topic(): + """Тест отправки в тему форума""" + + bot_token = BOT_TOKEN + base_url = f"https://api.telegram.org/bot{bot_token}" + + # Текст для отправки + text = "🗑️ ТЕСТ Юмор про мусор\n\nПочему мусорный бак всегда полон? Потому что у него нет проблемы с переполнением! 😂" + + # Тема мемов + message_thread_id = 6262 + + print(f"📤 Отправка в чат {ADMIN_CHAT_ID}, тема {message_thread_id}...") + + async with aiohttp.ClientSession() as session: + url = f"{base_url}/sendMessage" + params = { + 'chat_id': ADMIN_CHAT_ID, + 'text': text, + 'parse_mode': 'HTML', + 'message_thread_id': message_thread_id + } + + async with session.post(url, json=params, timeout=30) as resp: + print(f"Status: {resp.status}") + result = await resp.json() + + if resp.status == 200: + print("✅ Успешно отправлено!") + print(f"Message ID: {result['result']['message_id']}") + else: + print(f"❌ Ошибка: {result}") + error_text = await resp.text() + print(f"Текст ошибки: {error_text}") + +if __name__ == '__main__': + asyncio.run(test_send_to_topic()) diff --git a/test_scheduled_posts.py b/test_scheduled_posts.py new file mode 100644 index 0000000..f2622d6 --- /dev/null +++ b/test_scheduled_posts.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +Тестирование запланированных постов +""" +import asyncio +import aiohttp +from datetime import datetime, timedelta, timezone +import sys +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(BASE_DIR)) + +from config import WEB_ADMIN_LOGIN, WEB_ADMIN_PASSWORD + +async def test_scheduled_posts(): + """Тест создание запланированного поста""" + + # Базовый URL веб-панели + base_url = "http://localhost:8000" + + # Данные для аутентификации из конфига + auth = aiohttp.BasicAuth(WEB_ADMIN_LOGIN, WEB_ADMIN_PASSWORD) + + print(f"🔐 Используем логин: {WEB_ADMIN_LOGIN}") + + # Время через 2 минуты от сейчас + scheduled_time = datetime.now(timezone.utc) + timedelta(minutes=2) + + # Тест 1: Создание поста + print("\n📅 Тест 1: Создание запланированного поста...") + + form_data = aiohttp.FormData() + form_data.add_field('text', '🗑️ Юмор про мусор #1\n\nПочему мусорный бак всегда полон? Потому что у него нет проблемы с переполнением! 😂') + form_data.add_field('topic_name', 'memes') + form_data.add_field('recipients', 'chat_only') + form_data.add_field('scheduled_time', scheduled_time.isoformat()) + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{base_url}/api/scheduled_posts/create", + auth=auth, + data=form_data + ) as resp: + result = await resp.json() + print(f"Ответ: {result}") + + if result.get('success'): + print(f"✅ Пост создан! ID: {result.get('post_id')}") + else: + print(f"❌ Ошибка: {result.get('error')}") + return + + except Exception as e: + print(f"❌ Ошибка запроса: {e}") + return + + # Тест 2: Получение списка постов + print("\n📋 Тест 2: Получение списка постов...") + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{base_url}/api/scheduled_posts/list", + auth=auth + ) as resp: + result = await resp.json() + print(f"Найдено постов: {len(result.get('posts', []))}") + + for post in result.get('posts', []): + print(f" - ID {post['id']}: {post['topic_name']} в {post['scheduled_time']} [{post['status']}]") + + except Exception as e: + print(f"❌ Ошибка запроса: {e}") + return + + print("\n✅ Тесты завершены!") + print(f"⏳ Подожди 2 минуты и проверь что пост опубликовался автоматически!") + print(f"📱 Тебе придёт уведомление в Telegram когда пост будет опубликован!") + +if __name__ == '__main__': + asyncio.run(test_scheduled_posts()) diff --git a/web/app.py b/web/app.py index 0594ac7..044932e 100644 --- a/web/app.py +++ b/web/app.py @@ -20,7 +20,7 @@ import config from database.db import AsyncSessionLocal from database.models import ( User, Message, Poll, Ad, PaymentReminder, Schedule, - VerificationRequest, Service, Announcement, Event + VerificationRequest, Service, Announcement, Event, ScheduledPost ) logger = logging.getLogger(__name__) @@ -855,6 +855,177 @@ async def api_export_json(username: str = Depends(get_current_admin)): return JSONResponse({"success": True, "filepath": filepath}) +# ============================================================================ +# ЗАПЛАНИРОВАННЫЕ ПОСТЫ +# ============================================================================ + +@app.get("/scheduled_posts", response_class=HTMLResponse) +async def scheduled_posts_page(request: Request, username: str = Depends(get_current_admin)): + """Страница запланированных постов""" + return templates.TemplateResponse("scheduled_posts.html", { + "request": request, + "username": username + }) + + +@app.post("/api/scheduled_posts/create") +async def api_create_scheduled_post( + text: str = Form(...), + topic_name: str = Form(...), + recipients: str = Form("chat_only"), + scheduled_time: str = Form(...), + photo: UploadFile = File(None), + username: str = Depends(get_current_admin) +): + """Создать запланированный пост""" + from datetime import datetime + from config import ADMIN_CHAT_ID, get_topic_id + import aiohttp + + logger.info(f"📅 СОЗДАНИЕ ЗАПЛАНИРОВАННОГО ПОСТА: тема={topic_name}, время={scheduled_time}") + + try: + # Парсим время + scheduled_dt = datetime.fromisoformat(scheduled_time) + + # Убираем timezone если есть + if scheduled_dt.tzinfo is not None: + scheduled_dt = scheduled_dt.replace(tzinfo=None) + + # Проверяем что время в будущем + if scheduled_dt < datetime.utcnow(): + return JSONResponse({ + "success": False, + "error": "Время должно быть в будущем" + }, status_code=400) + + # Получаем topic_id из topic_name + topic_id = get_topic_id(topic_name) + + # Если есть фото - загружаем в Telegram + photo_file_id = None + if photo and photo.filename: + temp_path = Path("data") / f"scheduled_post_{photo.filename}" + with open(temp_path, "wb") as f: + content = await photo.read() + f.write(content) + + try: + async with aiohttp.ClientSession() as session: + url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto" + data = aiohttp.FormData() + data.add_field('chat_id', config.ADMIN_USER_ID) + data.add_field('photo', open(temp_path, 'rb'), filename=photo.filename) + data.add_field('caption', 'Preview') + + async with session.post(url, data=data) as resp: + result = await resp.json() + if result.get('ok'): + photo_file_id = result['result']['photo'][-1]['file_id'] + except Exception as e: + logger.error(f"Ошибка загрузки фото: {e}") + # Не блокируем из-за фото + photo_file_id = None + + # Сохраняем в БД + async with AsyncSessionLocal() as session: + scheduled_post = ScheduledPost( + text=text, + photo_file_id=photo_file_id, + topic_id=topic_id, + topic_name=topic_name, + recipients=recipients, + scheduled_time=scheduled_dt, + status='pending', + created_by=config.ADMIN_USER_ID + ) + session.add(scheduled_post) + await session.commit() + await session.refresh(scheduled_post) + + logger.info(f"✅ Пост создан: id={scheduled_post.id}") + + return JSONResponse({ + "success": True, + "message": f"Пост запланирован на {scheduled_dt.strftime('%d.%m.%Y %H:%M')}", + "post_id": scheduled_post.id + }) + + except ValueError as e: + return JSONResponse({ + "success": False, + "error": f"Неверный формат времени: {e}" + }, status_code=400) + except Exception as e: + logger.error(f"Ошибка создания поста: {e}") + return JSONResponse({ + "success": False, + "error": str(e) + }, status_code=500) + + +@app.get("/api/scheduled_posts/list") +async def api_list_scheduled_posts(username: str = Depends(get_current_admin)): + """Список запланированных постов""" + from datetime import datetime + + async with AsyncSessionLocal() as session: + stmt = select(ScheduledPost).order_by(ScheduledPost.scheduled_time.desc()) + result = await session.execute(stmt) + posts = list(result.scalars().all()) + + posts_data = [] + for post in posts: + posts_data.append({ + "id": post.id, + "text": post.text, + "topic_name": post.topic_name, + "topic_emoji": post.get_topic_emoji(), + "recipients": post.recipients, + "scheduled_time": post.scheduled_time.strftime('%d.%m.%Y %H:%M'), + "status": post.status, + "status_emoji": post.get_status_emoji(), + "created_at": post.created_at.strftime('%d.%m.%Y %H:%M'), + "has_photo": bool(post.photo_file_id) + }) + + return JSONResponse({ + "success": True, + "posts": posts_data + }) + + +@app.post("/api/scheduled_posts/{post_id}/cancel") +async def api_cancel_scheduled_post(post_id: int, username: str = Depends(get_current_admin)): + """Отменить запланированный пост""" + async with AsyncSessionLocal() as session: + stmt = select(ScheduledPost).where(ScheduledPost.id == post_id) + result = await session.execute(stmt) + post = result.scalar_one_or_none() + + if not post: + return JSONResponse({ + "success": False, + "error": "Пост не найден" + }, status_code=404) + + if post.status != 'pending': + return JSONResponse({ + "success": False, + "error": f"Нельзя отменить пост со статусом {post.status}" + }, status_code=400) + + post.status = 'cancelled' + await session.commit() + + logger.info(f"🚫 Пост отменён: id={post_id}") + + return JSONResponse({ + "success": True, + "message": "Пост отменён" + }) + + # ============================================================================ # НАСТРОЙКИ (перенесено в /broadcast) # ============================================================================ diff --git a/web/templates/scheduled_posts.html b/web/templates/scheduled_posts.html new file mode 100644 index 0000000..685d7c8 --- /dev/null +++ b/web/templates/scheduled_posts.html @@ -0,0 +1,288 @@ + + + + + Запланированные посты - Домовой Бот + + + + + +
+
+ +
+

Запланированные посты

+ + +
+
+
Создать новый пост
+
+
+
+
+ + + Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <emoji> +
+ +
+ + + JPG, PNG до 5MB +
+ Предпросмотр +
+
+ +
+
+ + + В какую тему отправить пост +
+ +
+ + + Кому отправить пост +
+
+ +
+
+ + + Когда опубликовать +
+ +
+ + + Во сколько опубликовать +
+
+ +
+ + Информация: Пост будет автоматически отправлен в выбранную тему форума в указанное время. +
+ + +
+ +
+
+
+ + +
+
+
Запланированные посты
+
+
+
+
+ +

Пока нет запланированных постов

+
+
+
+
+
+
+ + + +