bug fix
This commit is contained in:
11
Dockerfile.base
Normal file
11
Dockerfile.base
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -21,4 +21,3 @@ class States(IntEnum):
|
||||
TPL_NEW_FORMAT = 13
|
||||
TPL_NEW_CONTENT = 14
|
||||
TPL_NEW_KB = 15
|
||||
TPL_CONFIRM_DELETE = 16
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -13,4 +13,5 @@ httpx
|
||||
loguru
|
||||
wget
|
||||
redis
|
||||
jinja2
|
||||
jinja2
|
||||
APScheduler>=3.10.0
|
||||
Reference in New Issue
Block a user