This commit is contained in:
2025-08-17 14:15:46 +09:00
parent 5592014530
commit e5c3f79d2e
8 changed files with 1074 additions and 511 deletions

11
Dockerfile.base Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1

View File

@@ -82,9 +82,9 @@ class KbBuilder:
return InlineKeyboardMarkup(rows)
@staticmethod
def tpl_confirm_delete(tpl_id: int) -> InlineKeyboardMarkup:
def tpl_list_actions(tpl_id: int) -> InlineKeyboardMarkup:
rows = [
[InlineKeyboardButton("Да, удалить", callback_data=f"tpldelok:{tpl_id}")],
[InlineKeyboardButton("Отмена", callback_data="tpl:cancel")],
[InlineKeyboardButton("Удалить", callback_data=f"tpldelok:{tpl_id}")],
[InlineKeyboardButton("Назад", callback_data="tpl:cancel")],
]
return InlineKeyboardMarkup(rows)

View File

@@ -59,10 +59,7 @@ def build_app() -> Application:
States.TPL_NEW_FORMAT: [CallbackQueryHandler(wizard.tpl_new_format, pattern=r"^tplfmt:")],
States.TPL_NEW_CONTENT: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.tpl_new_content)],
States.TPL_NEW_KB: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.tpl_new_kb)],
States.TPL_CONFIRM_DELETE: [
CallbackQueryHandler(wizard.tpl_delete_ok, pattern=r"^tpldelok:"),
CallbackQueryHandler(wizard.choose_template_cancel, pattern=r"^tpl:cancel$"),
],
},
fallbacks=[CommandHandler("start", wizard.start)],
)
@@ -71,6 +68,7 @@ def build_app() -> Application:
app.add_handler(post_conv)
app.add_handler(tpl_conv)
app.add_handler(CommandHandler("tpl_list", wizard.tpl_list))
app.add_handler(CallbackQueryHandler(wizard.tpl_delete_ok, pattern=r"^tpldelok:"))
return app

View File

@@ -21,4 +21,3 @@ class States(IntEnum):
TPL_NEW_FORMAT = 13
TPL_NEW_CONTENT = 14
TPL_NEW_KB = 15
TPL_CONFIRM_DELETE = 16

View File

@@ -392,19 +392,31 @@ class EditorWizard:
return -1
async def tpl_list(self, update: Update, context: CallbackContext):
if not update.effective_user:
return -1
uid = update.effective_user.id
if update.callback_query and update.callback_query.data:
q = update.callback_query
if not q:
return -1
await q.answer()
page = int(q.data.split(":", 1)[1]) if ":" in q.data else 0
return await self._render_tpl_list(q, uid, page)
if not context or not context.user_data:
return -1
context.user_data["tpl_page"] = 0
return await self._render_tpl_list(update.message, update.effective_user.id, page=0)
async def tpl_confirm_delete(self, update: Update, context: CallbackContext):
from .keyboards import KbBuilder # локальный импорт уже есть, просто используем метод
q = update.callback_query
await q.answer()
tpl_id = int(q.data.split(":", 1)[1])
await q.edit_message_text("Удалить шаблон?", reply_markup=KbBuilder.tpl_confirm_delete(tpl_id))
return States.TPL_CONFIRM_DELETE
return await self._render_tpl_list(update.message, uid, page=0)
async def tpl_delete_ok(self, update: Update, context: CallbackContext):
if not update.callback_query or not update.effective_user:
return -1
q = update.callback_query
if not q.data:
return -1
await q.answer()
uid = update.effective_user.id
tpl_id = int(q.data.split(":", 1)[1])
@@ -417,7 +429,7 @@ class EditorWizard:
async def _render_preview_and_confirm(self, q_or_msg, uid: int, name: str, ctx_vars: dict):
rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars)
text = rendered["text"]
parse_mode = rendered.get("parse_mode") or self.sessions.get(uid).parse_mode or "HTML"
parse_mode = rendered.get("parse_mode") or (self.sessions.get(uid).parse_mode if self.sessions.get(uid) else None) or "HTML"
if hasattr(q_or_msg, "edit_message_text"):
await q_or_msg.edit_message_text(f"Предпросмотр:\n\n{text[:3500]}", parse_mode=parse_mode)
@@ -454,12 +466,13 @@ class EditorWizard:
return
token = settings.editor_bot_token
payload = build_payload(
ptype=s.type,
text=s.text,
ptype=str(s.type or "text"),
text=s.text or "",
media_file_id=s.media_file_id,
parse_mode=s.parse_mode or "HTML",
keyboard=s.keyboard,
)
from app.tasks.senders import send_post_task
send_post_task.delay(token, s.channel_id, payload)
await qmsg.edit_message_text("Отправка запущена.")
@@ -467,12 +480,13 @@ class EditorWizard:
s = self.sessions.get(uid)
token = settings.editor_bot_token
payload = build_payload(
ptype=s.type,
text=s.text,
ptype=str(s.type or "text"),
text=s.text or "",
media_file_id=s.media_file_id,
parse_mode=s.parse_mode or "HTML",
keyboard=s.keyboard,
)
from app.tasks.senders import send_post_task
send_post_task.apply_async(args=[token, s.channel_id, payload], eta=when)
@staticmethod

File diff suppressed because it is too large Load Diff

View File

@@ -1,395 +0,0 @@
from __future__ import annotations
import shlex
import logging
from datetime import datetime
from typing import Optional, Dict, List, Any
import time
from urllib.parse import urlparse
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Application, CommandHandler, MessageHandler, ConversationHandler,
CallbackQueryHandler, CallbackContext, filters,
)
from telegram.error import TelegramError
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select
from app.core.config import settings
from app.tasks.senders import send_post_task
from app.db.session import async_session_maker
from app.models.channel import Channel
from app.models.post import PostType
from app.services.templates import (
render_template_by_name, list_templates, count_templates,
create_template, delete_template, required_variables_of_template,
)
from jinja2 import TemplateError
# Настройка логирования
logger = logging.getLogger(__name__)
# Константы
MAX_MESSAGE_LENGTH = 4096
PAGE_SIZE = 8
SESSION_TIMEOUT = 3600 # 1 час
ALLOWED_URL_SCHEMES = ('http', 'https', 't.me')
# Состояния диалога
(
CHOOSE_CHANNEL, CHOOSE_TYPE, CHOOSE_FORMAT, ENTER_TEXT, ENTER_MEDIA, EDIT_KEYBOARD,
CONFIRM_SEND, ENTER_SCHEDULE, SELECT_TEMPLATE,
PREVIEW_VARS, PREVIEW_CONFIRM,
TPL_NEW_NAME, TPL_NEW_TYPE, TPL_NEW_FORMAT, TPL_NEW_CONTENT, TPL_NEW_KB, TPL_CONFIRM_DELETE,
) = range(16)
# In-memory сессии с метаданными
session: Dict[int, Dict[str, Any]] = {}
def validate_url(url: str) -> bool:
"""Проверка безопасности URL.
Args:
url: Строка URL для проверки
Returns:
bool: True если URL безопасен, False в противном случае
"""
try:
result = urlparse(url)
return all([
result.scheme in ALLOWED_URL_SCHEMES,
result.netloc,
len(url) < 2048 # Максимальная длина URL
])
except Exception as e:
logger.warning(f"URL validation failed: {e}")
return False
def validate_message_length(text: str) -> bool:
"""Проверка длины сообщения согласно лимитам Telegram.
Args:
text: Текст для проверки
Returns:
bool: True если длина в пределах лимита
"""
return len(text) <= MAX_MESSAGE_LENGTH
def update_session_activity(uid: int) -> None:
"""Обновление времени последней активности в сессии."""
if uid in session:
session[uid]['last_activity'] = time.time()
def cleanup_old_sessions() -> None:
"""Периодическая очистка старых сессий."""
current_time = time.time()
for uid in list(session.keys()):
if current_time - session[uid].get('last_activity', 0) > SESSION_TIMEOUT:
logger.info(f"Cleaning up session for user {uid}")
del session[uid]
def parse_template_invocation(s: str) -> tuple[str, dict]:
"""Разбор строки вызова шаблона.
Args:
s: Строка в формате #template_name key1=value1 key2=value2
Returns:
tuple: (имя_шаблона, словарь_параметров)
Raises:
ValueError: Если неверный формат строки
"""
s = s.strip()
if not s.startswith("#"):
raise ValueError("Имя шаблона должно начинаться с #")
parts = shlex.split(s)
if not parts:
raise ValueError("Пустой шаблон")
name = parts[0][1:]
args: dict[str, str] = {}
for tok in parts[1:]:
if "=" in tok:
k, v = tok.split("=", 1)
args[k.strip()] = v.strip()
return name, args
def parse_key_value_lines(text: str) -> dict:
"""Парсинг строк формата key=value.
Args:
text: Строки в формате key=value или key="quoted value"
Returns:
dict: Словарь параметров
"""
text = (text or "").strip()
if not text:
return {}
out = {}
if "\n" in text:
for line in text.splitlines():
if "=" in line:
k, v = line.split("=", 1)
v = v.strip().strip('"')
if k.strip(): # Проверка на пустой ключ
out[k.strip()] = v
else:
try:
for tok in shlex.split(text):
if "=" in tok:
k, v = tok.split("=", 1)
if k.strip(): # Проверка на пустой ключ
out[k.strip()] = v
except ValueError as e:
logger.warning(f"Error parsing key-value line: {e}")
return out
# -------- Команды верхнего уровня ---------
async def start(update: Update, context: CallbackContext) -> None:
"""Обработчик команды /start."""
update_session_activity(update.effective_user.id)
await update.message.reply_text(
"Привет! Я редактор. Команды:\n"
"/newpost — мастер поста\n"
"/tpl_new — создать шаблон\n"
"/tpl_list — список шаблонов"
)
async def newpost(update: Update, context: CallbackContext) -> int:
"""Начало создания нового поста."""
uid = update.effective_user.id
update_session_activity(uid)
session[uid] = {'last_activity': time.time()}
try:
async with async_session_maker() as s:
res = await s.execute(select(Channel).where(Channel.owner_id == uid).limit(50))
channels = list(res.scalars())
if not channels:
await update.message.reply_text(
"Пока нет каналов. Добавь через админку или команду /add_channel (в разработке)."
)
return ConversationHandler.END
kb = [
[InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")]
for ch in channels
]
await update.message.reply_text(
"Выбери канал для публикации:",
reply_markup=InlineKeyboardMarkup(kb)
)
return CHOOSE_CHANNEL
except Exception as e:
logger.error(f"Error in newpost: {e}")
await update.message.reply_text("Произошла ошибка. Попробуйте позже.")
return ConversationHandler.END
async def choose_channel(update: Update, context: CallbackContext) -> int:
"""Обработка выбора канала."""
q = update.callback_query
await q.answer()
uid = update.effective_user.id
update_session_activity(uid)
try:
ch_id = int(q.data.split(":")[1])
session[uid]["channel_id"] = ch_id
kb = [
[
InlineKeyboardButton("Текст", callback_data="type:text"),
InlineKeyboardButton("Фото", callback_data="type:photo")
],
[
InlineKeyboardButton("Видео", callback_data="type:video"),
InlineKeyboardButton("GIF", callback_data="type:animation")
],
]
await q.edit_message_text(
"Тип поста:",
reply_markup=InlineKeyboardMarkup(kb)
)
return CHOOSE_TYPE
except ValueError:
await q.edit_message_text("Ошибка: неверный формат ID канала")
return ConversationHandler.END
except Exception as e:
logger.error(f"Error in choose_channel: {e}")
await q.edit_message_text("Произошла ошибка. Попробуйте заново.")
return ConversationHandler.END
# ... [Остальные функции обновляются аналогично] ...
async def enter_schedule(update: Update, context: CallbackContext) -> int:
"""Обработка ввода времени для отложенной публикации."""
uid = update.effective_user.id
update_session_activity(uid)
try:
when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M")
if when < datetime.now():
await update.message.reply_text(
"Дата должна быть в будущем. Укажите корректное время в формате YYYY-MM-DD HH:MM"
)
return ENTER_SCHEDULE
await _dispatch_with_eta(uid, when)
await update.message.reply_text("Задача запланирована.")
return ConversationHandler.END
except ValueError:
await update.message.reply_text(
"Неверный формат даты. Используйте формат YYYY-MM-DD HH:MM"
)
return ENTER_SCHEDULE
except Exception as e:
logger.error(f"Error scheduling post: {e}")
await update.message.reply_text("Ошибка планирования. Попробуйте позже.")
return ConversationHandler.END
async def _dispatch_with_eta(uid: int, when: datetime) -> None:
"""Отправка отложенного поста."""
data = session.get(uid)
if not data:
raise ValueError("Сессия потеряна")
token = settings.editor_bot_token
try:
payload = build_payload(
ptype=data.get("type"),
text=data.get("text"),
media_file_id=data.get("media_file_id"),
parse_mode=data.get("parse_mode") or "HTML",
keyboard=data.get("keyboard"),
)
# Проверка длины сообщения
if not validate_message_length(payload.get("text", "")):
raise ValueError("Превышен максимальный размер сообщения")
# Проверка URL в клавиатуре
if keyboard := payload.get("keyboard"):
for row in keyboard.get("rows", []):
for btn in row:
if "url" in btn and not validate_url(btn["url"]):
raise ValueError(f"Небезопасный URL: {btn['url']}")
send_post_task.apply_async(
args=[token, data["channel_id"], payload],
eta=when
)
except Exception as e:
logger.error(f"Error in _dispatch_with_eta: {e}")
raise
def main():
"""Инициализация и запуск бота."""
try:
# Настройка логирования
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO
)
# Инициализация планировщика
scheduler = AsyncIOScheduler()
scheduler.add_job(cleanup_old_sessions, 'interval', minutes=30)
scheduler.start()
app = Application.builder().token(settings.editor_bot_token).build()
# Регистрация обработчиков
post_conv = ConversationHandler(
entry_points=[CommandHandler("newpost", newpost)],
states={
CHOOSE_CHANNEL: [CallbackQueryHandler(choose_channel, pattern=r"^channel:")],
CHOOSE_TYPE: [CallbackQueryHandler(choose_type, pattern=r"^type:")],
CHOOSE_FORMAT: [CallbackQueryHandler(choose_format, pattern=r"^fmt:")],
ENTER_TEXT: [
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text),
CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$"),
],
SELECT_TEMPLATE: [
CallbackQueryHandler(choose_template_apply, pattern=r"^tpluse:"),
CallbackQueryHandler(choose_template_preview, pattern=r"^tplprev:"),
CallbackQueryHandler(choose_template_navigate, pattern=r"^tplpage:"),
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"),
],
PREVIEW_VARS: [
MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars)
],
PREVIEW_CONFIRM: [
CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"),
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"),
],
ENTER_MEDIA: [
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_media)
],
EDIT_KEYBOARD: [
MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard)
],
CONFIRM_SEND: [
CallbackQueryHandler(confirm_send, pattern=r"^send:")
],
ENTER_SCHEDULE: [
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule)
],
},
fallbacks=[CommandHandler("start", start)],
)
tpl_conv = ConversationHandler(
entry_points=[CommandHandler("tpl_new", tpl_new_start)],
states={
TPL_NEW_NAME: [
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_name)
],
TPL_NEW_TYPE: [
CallbackQueryHandler(tpl_new_type, pattern=r"^tpltype:")
],
TPL_NEW_FORMAT: [
CallbackQueryHandler(tpl_new_format, pattern=r"^tplfmt:")
],
TPL_NEW_CONTENT: [
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_content)
],
TPL_NEW_KB: [
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_kb)
],
TPL_CONFIRM_DELETE: [
CallbackQueryHandler(tpl_delete_ok, pattern=r"^tpldelok:"),
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"),
],
},
fallbacks=[CommandHandler("start", start)],
)
app.add_handler(CommandHandler("start", start))
app.add_handler(post_conv)
app.add_handler(tpl_conv)
app.add_handler(CommandHandler("tpl_list", tpl_list))
# Запуск бота
app.run_polling(allowed_updates=Update.ALL_TYPES)
except Exception as e:
logger.critical(f"Critical error in main: {e}")
raise
if __name__ == "__main__":
main()

View File

@@ -13,4 +13,5 @@ httpx
loguru
wget
redis
jinja2
jinja2
APScheduler>=3.10.0