✨ 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:
parent
1825d26230
commit
fc06ec4a12
8 changed files with 867 additions and 1 deletions
48
database/migrate_add_scheduled_posts.py
Normal file
48
database/migrate_add_scheduled_posts.py
Normal 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()
|
||||||
|
|
@ -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, '❓')
|
||||||
|
|
|
||||||
1
main.py
1
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 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(
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,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):
|
||||||
"""Остановка планировщика"""
|
"""Остановка планировщика"""
|
||||||
self.scheduler.shutdown()
|
self.scheduler.shutdown()
|
||||||
|
|
@ -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
51
test_direct_send.py
Normal 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
83
test_scheduled_posts.py
Normal 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())
|
||||||
173
web/app.py
173
web/app.py
|
|
@ -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)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
288
web/templates/scheduled_posts.html
Normal file
288
web/templates/scheduled_posts.html
Normal 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: <b>жирный</b>, <i>курсив</i>, <emoji></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>
|
||||||
Loading…
Reference in a new issue