v1.3 - Scheduled Posts: отложенная публикация постов по расписанию

- Новая страница /scheduled_posts в веб-панели
- Создание постов с фото/текстом в любую тему форума
- Планирование даты и времени публикации
- Автоматическая отправка по расписанию (каждую минуту)
- Поддержка всех 10 тем форума
- Уведомления админа о публикации/ошибках
- Миграция БД: таблица scheduled_posts
- Тесты: test_scheduled_posts.py

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Admin 2026-04-05 15:52:47 +04:00
parent 1825d26230
commit fc06ec4a12
8 changed files with 867 additions and 1 deletions

View file

@ -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()

View file

@ -595,3 +595,63 @@ class InitiativeGroup(Base):
def __repr__(self): def __repr__(self):
return f"<InitiativeGroup user={self.user_id} role={self.role}>" return f"<InitiativeGroup user={self.user_id} role={self.role}>"
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"<ScheduledPost {self.id} at {self.scheduled_time} status={self.status}>"
@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, '')

View file

@ -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 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.scheduler import Scheduler
from services.web_server import WebServerService from services.web_server import WebServerService
from database.models import ScheduledPost # Для новой модели
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(

View file

@ -100,6 +100,16 @@ class Scheduler:
self.scheduler.start() self.scheduler.start()
logger.info('✅ Планировщик запущен') 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): def stop(self):
"""Остановка планировщика""" """Остановка планировщика"""
@ -281,3 +291,157 @@ class Scheduler:
logger.error(f'Не удалось отправить напоминание о событии: {e}') logger.error(f'Не удалось отправить напоминание о событии: {e}')
except Exception as e: except Exception as e:
logger.error(f'Ошибка напоминаний о событиях: {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'✅ <b>Пост опубликован!</b>\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'❌ <b>Ошибка публикации поста!</b>\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}')

51
test_direct_send.py Normal file
View file

@ -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 = "<b>🗑️ ТЕСТ Юмор про мусор</b>\n\n<i>Почему мусорный бак всегда полон? Потому что у него нет проблемы с переполнением! 😂</i>"
# Тема мемов
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())

83
test_scheduled_posts.py Normal file
View file

@ -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', '<b>🗑️ Юмор про мусор #1</b>\n\n<i>Почему мусорный бак всегда полон? Потому что у него нет проблемы с переполнением! 😂</i>')
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())

View file

@ -20,7 +20,7 @@ import config
from database.db import AsyncSessionLocal from database.db import AsyncSessionLocal
from database.models import ( from database.models import (
User, Message, Poll, Ad, PaymentReminder, Schedule, User, Message, Poll, Ad, PaymentReminder, Schedule,
VerificationRequest, Service, Announcement, Event VerificationRequest, Service, Announcement, Event, ScheduledPost
) )
logger = logging.getLogger(__name__) 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}) 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) # НАСТРОЙКИ (перенесено в /broadcast)
# ============================================================================ # ============================================================================

View file

@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Запланированные посты - Домовой Бот</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
.sidebar { min-height: 100vh; background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%); }
.sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 1rem; }
.sidebar .nav-link:hover, .sidebar .nav-link.active { color: white; background-color: rgba(255,255,255,0.1); }
.content { padding: 2rem; }
#preview { max-width: 400px; margin-top: 1rem; display: none; }
#preview img { max-width: 100%; border-radius: 8px; }
.post-card { transition: transform 0.2s; }
.post-card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
</style>
</head>
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-2 sidebar p-0">
<div class="p-3 text-white text-center">
<h4><i class="bi bi-house-door"></i> Домовой Бот</h4>
</div>
<nav class="nav flex-column">
<a class="nav-link" href="/"><i class="bi bi-speedometer2"></i> Дашборд</a>
<a class="nav-link" href="/users"><i class="bi bi-people"></i> Пользователи</a>
<a class="nav-link" href="/verification"><i class="bi bi-shield-check"></i> Верификация</a>
<a class="nav-link" href="/phones"><i class="bi bi-telephone"></i> Телефоны</a>
<a class="nav-link" href="/broadcast"><i class="bi bi-broadcast"></i> Рассылки</a>
<a class="nav-link active" href="/scheduled_posts"><i class="bi bi-calendar-event"></i> Запланированные посты</a>
<a class="nav-link" href="/polls"><i class="bi bi-bar-chart"></i> Опросы</a>
<a class="nav-link" href="/events"><i class="bi bi-calendar-event"></i> События</a>
<a class="nav-link" href="/schedules"><i class="bi bi-clock"></i> Расписания</a>
<a class="nav-link" href="/export"><i class="bi bi-download"></i> Экспорт</a>
</nav>
</div>
<div class="col-md-10 content">
<h2 class="mb-4"><i class="bi bi-calendar2-plus"></i> Запланированные посты</h2>
<!-- Форма создания поста -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-plus-circle"></i> Создать новый пост</h5>
</div>
<div class="card-body">
<form id="scheduledPostForm" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">Текст поста</label>
<textarea class="form-control" id="postText" rows="5"
placeholder="Введите текст поста..." required></textarea>
<small class="text-muted">Поддерживается HTML: &lt;b&gt;жирный&lt;/b&gt;, &lt;i&gt;курсив&lt;/i&gt;, &lt;emoji&gt;</small>
</div>
<div class="mb-3">
<label class="form-label">Картинка (необязательно)</label>
<input type="file" class="form-control" id="postPhoto" accept="image/*"
onchange="previewImage()">
<small class="text-muted">JPG, PNG до 5MB</small>
<div id="preview">
<img id="previewImg" alt="Предпросмотр">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Тема форума</label>
<select class="form-select" id="postTopic" required>
<option value="general">💬 Общий чат</option>
<option value="memes">😂 Мемы</option>
<option value="ads">📢 Важные объявления</option>
<option value="meetings">🏛️ Собрания собственников</option>
<option value="kgm">🗑️ Вывоз КГМ</option>
<option value="lostfound">🔍 Потеряшки</option>
<option value="auto">🚗 Для автовладельцев</option>
<option value="phones">📞 Важные телефоны</option>
<option value="links">🔗 Ссылки</option>
<option value="uo">📝 Переписка с УО</option>
</select>
<small class="text-muted">В какую тему отправить пост</small>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Получатели</label>
<select class="form-select" id="postRecipients">
<option value="chat_only">📢 Только в чат</option>
<option value="all_verified">✅ Всем верифицированным в личку</option>
<option value="all_and_chat">✅ Всем + в чат</option>
</select>
<small class="text-muted">Кому отправить пост</small>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Дата публикации</label>
<input type="date" class="form-control" id="postDate" required>
<small class="text-muted">Когда опубликовать</small>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Время публикации</label>
<input type="time" class="form-control" id="postTime" required>
<small class="text-muted">Во сколько опубликовать</small>
</div>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<b>Информация:</b> Пост будет автоматически отправлен в выбранную тему форума в указанное время.
</div>
<button type="submit" class="btn btn-success btn-lg">
<i class="bi bi-calendar-check"></i> Запланировать пост
</button>
</form>
<div id="result" class="mt-4"></div>
</div>
</div>
<!-- Список запланированных постов -->
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="bi bi-list-check"></i> Запланированные посты</h5>
</div>
<div class="card-body">
<div id="postsList"></div>
<div id="noPosts" class="text-center text-muted py-5">
<i class="bi bi-calendar-x" style="font-size: 3rem;"></i>
<p class="mt-3">Пока нет запланированных постов</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Установка минимальной даты (сегодня)
document.addEventListener('DOMContentLoaded', function() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('postDate').setAttribute('min', today);
loadPosts();
});
function previewImage() {
const file = document.getElementById('postPhoto').files[0];
const preview = document.getElementById('preview');
const previewImg = document.getElementById('previewImg');
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
preview.style.display = 'block';
}
reader.readAsDataURL(file);
} else {
preview.style.display = 'none';
}
}
// Отправка формы
document.getElementById('scheduledPostForm').addEventListener('submit', async (e) => {
e.preventDefault();
const text = document.getElementById('postText').value;
const photo = document.getElementById('postPhoto').files[0];
const topic = document.getElementById('postTopic').value;
const recipients = document.getElementById('postRecipients').value;
const date = document.getElementById('postDate').value;
const time = document.getElementById('postTime').value;
const resultDiv = document.getElementById('result');
if (!text.trim()) {
alert('Введите текст поста!');
return;
}
if (!date || !time) {
alert('Выберите дату и время!');
return;
}
const scheduledTime = `${date}T${time}:00`;
const formData = new FormData();
formData.append('text', text);
formData.append('topic_name', topic);
formData.append('recipients', recipients);
formData.append('scheduled_time', scheduledTime);
if (photo) {
formData.append('photo', photo);
}
try {
const r = await fetch('/api/scheduled_posts/create', {
method: 'POST',
body: formData
});
const result = await r.json();
if (result.success) {
resultDiv.innerHTML = `<div class="alert alert-success">✅ ${result.message}</div>`;
document.getElementById('scheduledPostForm').reset();
document.getElementById('preview').style.display = 'none';
loadPosts();
} else {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`;
}
} catch (err) {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`;
}
});
// Загрузка постов
async function loadPosts() {
try {
const r = await fetch('/api/scheduled_posts/list');
const result = await r.json();
const postsList = document.getElementById('postsList');
const noPosts = document.getElementById('noPosts');
if (result.success && result.posts.length > 0) {
noPosts.style.display = 'none';
postsList.innerHTML = '';
result.posts.forEach(post => {
const card = document.createElement('div');
card.className = 'card mb-3 post-card';
card.innerHTML = `
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-1">${post.topic_emoji} ${post.topic_name || 'Без темы'}</h6>
<span class="badge bg-${post.status === 'pending' ? 'warning' : post.status === 'sent' ? 'success' : post.status === 'failed' ? 'danger' : 'secondary'}">
${post.status_emoji} ${post.status}
</span>
</div>
<p class="mb-2">${post.text.substring(0, 100)}${post.text.length > 100 ? '...' : ''}</p>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
📅 ${post.scheduled_time}
</small>
${post.status === 'pending' ? `
<button class="btn btn-sm btn-danger" onclick="cancelPost(${post.id})">
<i class="bi bi-x-circle"></i> Отменить
</button>
` : ''}
</div>
</div>
`;
postsList.appendChild(card);
});
} else {
noPosts.style.display = 'block';
postsList.innerHTML = '';
}
} catch (err) {
console.error('Ошибка загрузки постов:', err);
}
}
// Отмена поста
async function cancelPost(postId) {
if (!confirm('Отменить этот пост?')) return;
try {
const r = await fetch(`/api/scheduled_posts/${postId}/cancel`, {
method: 'POST'
});
const result = await r.json();
if (result.success) {
alert('✅ Пост отменён');
loadPosts();
} else {
alert('❌ Ошибка: ' + (result.error || 'Неизвестная'));
}
} catch (err) {
alert('❌ Ошибка: ' + err.message);
}
}
</script>
</body>
</html>