init commit
This commit is contained in:
0
app/bots/__init__.py
Normal file
0
app/bots/__init__.py
Normal file
0
app/bots/editor/__init__.py
Normal file
0
app/bots/editor/__init__.py
Normal file
90
app/bots/editor/keyboards.py
Normal file
90
app/bots/editor/keyboards.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, List, Tuple, Optional
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
|
||||
class KbBuilder:
|
||||
@staticmethod
|
||||
def channels(channels: Iterable) -> InlineKeyboardMarkup:
|
||||
rows = [[InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")]
|
||||
for ch in channels]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def post_types() -> InlineKeyboardMarkup:
|
||||
rows = [
|
||||
[InlineKeyboardButton("Текст", callback_data="type:text"),
|
||||
InlineKeyboardButton("Фото", callback_data="type:photo")],
|
||||
[InlineKeyboardButton("Видео", callback_data="type:video"),
|
||||
InlineKeyboardButton("GIF", callback_data="type:animation")],
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def parse_modes() -> InlineKeyboardMarkup:
|
||||
rows = [[InlineKeyboardButton("HTML", callback_data="fmt:HTML"),
|
||||
InlineKeyboardButton("MarkdownV2", callback_data="fmt:MarkdownV2")]]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def send_confirm() -> InlineKeyboardMarkup:
|
||||
rows = [
|
||||
[InlineKeyboardButton("Отправить сейчас", callback_data="send:now")],
|
||||
[InlineKeyboardButton("Запланировать", callback_data="send:schedule")],
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def templates_list(items: List, page: int, total: int, page_size: int) -> InlineKeyboardMarkup:
|
||||
rows: List[List[InlineKeyboardButton]] = []
|
||||
for t in items:
|
||||
rows.append([
|
||||
InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"),
|
||||
InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}")
|
||||
])
|
||||
|
||||
nav: List[InlineKeyboardButton] = []
|
||||
if page > 0:
|
||||
nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}"))
|
||||
if (page + 1) * page_size < total:
|
||||
nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}"))
|
||||
if nav:
|
||||
rows.append(nav)
|
||||
|
||||
rows.append([InlineKeyboardButton("Отмена", callback_data="tpl:cancel")])
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def preview_actions() -> InlineKeyboardMarkup:
|
||||
rows = [
|
||||
[InlineKeyboardButton("✅ Использовать", callback_data="pv:use")],
|
||||
[InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")],
|
||||
[InlineKeyboardButton("Отмена", callback_data="tpl:cancel")],
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def tpl_types() -> InlineKeyboardMarkup:
|
||||
rows = [
|
||||
[InlineKeyboardButton("Текст", callback_data="tpltype:text"),
|
||||
InlineKeyboardButton("Фото", callback_data="tpltype:photo")],
|
||||
[InlineKeyboardButton("Видео", callback_data="tpltype:video"),
|
||||
InlineKeyboardButton("GIF", callback_data="tpltype:animation")],
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def tpl_formats() -> InlineKeyboardMarkup:
|
||||
rows = [
|
||||
[InlineKeyboardButton("HTML (по умолчанию)", callback_data="tplfmt:HTML")],
|
||||
[InlineKeyboardButton("MarkdownV2", callback_data="tplfmt:MarkdownV2")],
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def tpl_confirm_delete(tpl_id: int) -> InlineKeyboardMarkup:
|
||||
rows = [
|
||||
[InlineKeyboardButton("Да, удалить", callback_data=f"tpldelok:{tpl_id}")],
|
||||
[InlineKeyboardButton("Отмена", callback_data="tpl:cancel")],
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
51
app/bots/editor/messages.py
Normal file
51
app/bots/editor/messages.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
import shlex
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class MessageParsers:
|
||||
@staticmethod
|
||||
def parse_template_invocation(s: str) -> tuple[str, Dict[str, str]]:
|
||||
"""
|
||||
Пример: "#promo title='Hi' url=https://x.y"
|
||||
-> ("promo", {"title":"Hi", "url":"https://x.y"})
|
||||
"""
|
||||
s = (s or "").strip()
|
||||
if not s.startswith("#"):
|
||||
raise ValueError("not a template invocation")
|
||||
parts = shlex.split(s)
|
||||
name = parts[0][1:]
|
||||
args: Dict[str, str] = {}
|
||||
for tok in parts[1:]:
|
||||
if "=" in tok:
|
||||
k, v = tok.split("=", 1)
|
||||
args[k] = v
|
||||
return name, args
|
||||
|
||||
@staticmethod
|
||||
def parse_key_value_lines(text: str) -> Dict[str, str]:
|
||||
"""
|
||||
Поддерживает:
|
||||
- построчно:
|
||||
key=value
|
||||
key2="quoted value"
|
||||
- одной строкой:
|
||||
key=value key2="quoted value"
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
if "\n" in text:
|
||||
out: Dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
out[k.strip()] = v.strip().strip('"')
|
||||
return out
|
||||
|
||||
out: Dict[str, str] = {}
|
||||
for tok in shlex.split(text):
|
||||
if "=" in tok:
|
||||
k, v = tok.split("=", 1)
|
||||
out[k] = v
|
||||
return out
|
||||
84
app/bots/editor/oop_app.py
Normal file
84
app/bots/editor/oop_app.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
from telegram import Update
|
||||
from telegram.ext import (
|
||||
Application, CommandHandler, MessageHandler, ConversationHandler,
|
||||
CallbackQueryHandler, CallbackContext, filters,
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
from .states import States
|
||||
from .session import SessionStore
|
||||
from .wizard import EditorWizard
|
||||
|
||||
|
||||
def build_app() -> Application:
|
||||
sessions = SessionStore()
|
||||
wizard = EditorWizard(sessions)
|
||||
|
||||
app = Application.builder().token(settings.editor_bot_token).build()
|
||||
|
||||
# Мастер поста
|
||||
post_conv = ConversationHandler(
|
||||
entry_points=[CommandHandler("newpost", wizard.newpost)],
|
||||
states={
|
||||
States.CHOOSE_CHANNEL: [CallbackQueryHandler(wizard.choose_channel, pattern=r"^channel:")],
|
||||
States.CHOOSE_TYPE: [CallbackQueryHandler(wizard.choose_type, pattern=r"^type:")],
|
||||
States.CHOOSE_FORMAT: [CallbackQueryHandler(wizard.choose_format, pattern=r"^fmt:")],
|
||||
|
||||
States.ENTER_TEXT: [
|
||||
MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.enter_text),
|
||||
CallbackQueryHandler(wizard.choose_template_open, pattern=r"^tpl:choose$"),
|
||||
],
|
||||
States.SELECT_TEMPLATE: [
|
||||
CallbackQueryHandler(wizard.choose_template_apply, pattern=r"^tpluse:"),
|
||||
CallbackQueryHandler(wizard.choose_template_preview, pattern=r"^tplprev:"),
|
||||
CallbackQueryHandler(wizard.choose_template_navigate, pattern=r"^tplpage:"),
|
||||
CallbackQueryHandler(wizard.choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||
],
|
||||
States.PREVIEW_VARS: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.preview_collect_vars)],
|
||||
States.PREVIEW_CONFIRM: [
|
||||
CallbackQueryHandler(wizard.preview_confirm, pattern=r"^pv:(use|edit)$"),
|
||||
CallbackQueryHandler(wizard.choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||
],
|
||||
|
||||
States.ENTER_MEDIA: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.enter_media)],
|
||||
States.EDIT_KEYBOARD: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.edit_keyboard)],
|
||||
|
||||
States.CONFIRM_SEND: [CallbackQueryHandler(wizard.confirm_send, pattern=r"^send:")],
|
||||
States.ENTER_SCHEDULE: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.enter_schedule)],
|
||||
},
|
||||
fallbacks=[CommandHandler("start", wizard.start)],
|
||||
)
|
||||
|
||||
# Мастер шаблонов
|
||||
tpl_conv = ConversationHandler(
|
||||
entry_points=[CommandHandler("tpl_new", wizard.tpl_new_start)],
|
||||
states={
|
||||
States.TPL_NEW_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.tpl_new_name)],
|
||||
States.TPL_NEW_TYPE: [CallbackQueryHandler(wizard.tpl_new_type, pattern=r"^tpltype:")],
|
||||
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)],
|
||||
)
|
||||
|
||||
app.add_handler(CommandHandler("start", wizard.start))
|
||||
app.add_handler(post_conv)
|
||||
app.add_handler(tpl_conv)
|
||||
app.add_handler(CommandHandler("tpl_list", wizard.tpl_list))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
app = build_app()
|
||||
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
47
app/bots/editor/session.py
Normal file
47
app/bots/editor/session.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
DEFAULT_TTL = 60 * 60 # 1 час
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserSession:
|
||||
channel_id: Optional[int] = None
|
||||
type: Optional[str] = None # text/photo/video/animation
|
||||
parse_mode: Optional[str] = None # HTML/MarkdownV2
|
||||
text: Optional[str] = None
|
||||
media_file_id: Optional[str] = None
|
||||
keyboard: Optional[dict] = None # {"rows": [[{"text","url"}], ...]}
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
|
||||
def touch(self) -> None:
|
||||
self.last_activity = time.time()
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""Простое и быстрое in-memory хранилище с авто-очисткой."""
|
||||
|
||||
def __init__(self, ttl: int = DEFAULT_TTL) -> None:
|
||||
self._data: Dict[int, UserSession] = {}
|
||||
self._ttl = ttl
|
||||
|
||||
def get(self, uid: int) -> UserSession:
|
||||
s = self._data.get(uid)
|
||||
if not s:
|
||||
s = UserSession()
|
||||
self._data[uid] = s
|
||||
s.touch()
|
||||
self._cleanup()
|
||||
return s
|
||||
|
||||
def drop(self, uid: int) -> None:
|
||||
self._data.pop(uid, None)
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
now = time.time()
|
||||
for uid in list(self._data.keys()):
|
||||
if now - self._data[uid].last_activity > self._ttl:
|
||||
del self._data[uid]
|
||||
24
app/bots/editor/states.py
Normal file
24
app/bots/editor/states.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class States(IntEnum):
|
||||
CHOOSE_CHANNEL = 0
|
||||
CHOOSE_TYPE = 1
|
||||
CHOOSE_FORMAT = 2
|
||||
ENTER_TEXT = 3
|
||||
ENTER_MEDIA = 4
|
||||
EDIT_KEYBOARD = 5
|
||||
CONFIRM_SEND = 6
|
||||
ENTER_SCHEDULE = 7
|
||||
|
||||
SELECT_TEMPLATE = 8
|
||||
PREVIEW_VARS = 9
|
||||
PREVIEW_CONFIRM = 10
|
||||
|
||||
TPL_NEW_NAME = 11
|
||||
TPL_NEW_TYPE = 12
|
||||
TPL_NEW_FORMAT = 13
|
||||
TPL_NEW_CONTENT = 14
|
||||
TPL_NEW_KB = 15
|
||||
TPL_CONFIRM_DELETE = 16
|
||||
485
app/bots/editor/wizard.py
Normal file
485
app/bots/editor/wizard.py
Normal file
@@ -0,0 +1,485 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
|
||||
from telegram import Update
|
||||
from telegram.ext import CallbackContext
|
||||
|
||||
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.services.templates import (
|
||||
render_template_by_name, list_templates, count_templates,
|
||||
create_template, delete_template, required_variables_of_template,
|
||||
)
|
||||
from jinja2 import TemplateError
|
||||
|
||||
from .states import States
|
||||
from .session import SessionStore
|
||||
from .messages import MessageParsers
|
||||
from .keyboards import KbBuilder
|
||||
|
||||
|
||||
# Заглушка для build_payload, если сервиса нет
|
||||
try:
|
||||
from app.services.telegram import build_payload # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
def build_payload(ptype: str, text: str | None, media_file_id: str | None,
|
||||
parse_mode: str | None, keyboard: dict | None) -> dict:
|
||||
return {
|
||||
"type": ptype,
|
||||
"text": text,
|
||||
"media_file_id": media_file_id,
|
||||
"parse_mode": parse_mode,
|
||||
"keyboard": keyboard,
|
||||
}
|
||||
|
||||
|
||||
PAGE_SIZE = 8
|
||||
|
||||
|
||||
class EditorWizard:
|
||||
"""Инкапсулирует весь сценарий мастера и управление шаблонами."""
|
||||
|
||||
def __init__(self, sessions: SessionStore) -> None:
|
||||
self.sessions = sessions
|
||||
|
||||
# ---------- Команды верхнего уровня ----------
|
||||
|
||||
async def start(self, update: Update, context: CallbackContext):
|
||||
await update.message.reply_text(
|
||||
"Привет! Я редактор.\n"
|
||||
"Команды: /newpost — мастер поста, /tpl_new — создать шаблон, /tpl_list — список шаблонов."
|
||||
)
|
||||
|
||||
async def newpost(self, update: Update, context: CallbackContext):
|
||||
uid = update.effective_user.id
|
||||
s = self.sessions.get(uid) # инициализация
|
||||
|
||||
async with async_session_maker() as db:
|
||||
res = await db.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 -1
|
||||
|
||||
await update.message.reply_text("Выбери канал для публикации:", reply_markup=KbBuilder.channels(channels))
|
||||
return States.CHOOSE_CHANNEL
|
||||
|
||||
# ---------- Выбор канала/типа/формата ----------
|
||||
|
||||
async def choose_channel(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
s = self.sessions.get(uid)
|
||||
|
||||
ch_id = int(q.data.split(":", 1)[1])
|
||||
s.channel_id = ch_id
|
||||
|
||||
await q.edit_message_text("Тип поста:", reply_markup=KbBuilder.post_types())
|
||||
return States.CHOOSE_TYPE
|
||||
|
||||
async def choose_type(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
s = self.sessions.get(uid)
|
||||
|
||||
s.type = q.data.split(":", 1)[1]
|
||||
await q.edit_message_text("Выбери формат разметки (по умолчанию HTML):", reply_markup=KbBuilder.parse_modes())
|
||||
return States.CHOOSE_FORMAT
|
||||
|
||||
async def choose_format(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
s = self.sessions.get(uid)
|
||||
|
||||
s.parse_mode = q.data.split(":", 1)[1]
|
||||
|
||||
if s.type == "text":
|
||||
await q.edit_message_text("Отправь текст сообщения или выбери шаблон:", reply_markup=KbBuilder.templates_list([], 0, 0, PAGE_SIZE))
|
||||
# Доп. сообщение с кнопкой «Выбрать шаблон»
|
||||
await q.message.reply_text("Нажми «Выбрать шаблон»", reply_markup=None)
|
||||
return States.ENTER_TEXT
|
||||
else:
|
||||
await q.edit_message_text(
|
||||
"Пришли медиадескриптор (file_id) и, при желании, подпись.\nФормат: FILE_ID|Подпись"
|
||||
)
|
||||
return States.ENTER_MEDIA
|
||||
|
||||
# ---------- Ввод текста/медиа ----------
|
||||
|
||||
async def enter_text(self, update: Update, context: CallbackContext):
|
||||
uid = update.effective_user.id
|
||||
s = self.sessions.get(uid)
|
||||
text = update.message.text or ""
|
||||
|
||||
if text.strip().startswith("#"):
|
||||
# Вызов шаблона: #name key=val ...
|
||||
try:
|
||||
name, ctx_vars = MessageParsers.parse_template_invocation(text)
|
||||
except ValueError:
|
||||
await update.message.reply_text("Не распознал шаблон. Пример: #promo title='Привет' url=https://x.y")
|
||||
return States.ENTER_TEXT
|
||||
|
||||
tpl_meta = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||
required = set(tpl_meta.get("_required", []))
|
||||
missing = sorted(list(required - set(ctx_vars.keys())))
|
||||
|
||||
context.user_data["preview"] = {"name": name, "provided": ctx_vars, "missing": missing}
|
||||
if missing:
|
||||
await update.message.reply_text(
|
||||
"Не хватает переменных: " + ", ".join(missing) +
|
||||
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||
)
|
||||
return States.PREVIEW_VARS
|
||||
|
||||
# все есть — применяем
|
||||
return await self._apply_template_and_confirm(update, uid, name, ctx_vars)
|
||||
|
||||
s.text = text
|
||||
await update.message.reply_text(
|
||||
"Добавить кнопки? Пришлите строки вида: Заголовок|URL или '-' чтобы пропустить."
|
||||
)
|
||||
return States.EDIT_KEYBOARD
|
||||
|
||||
async def enter_media(self, update: Update, context: CallbackContext):
|
||||
uid = update.effective_user.id
|
||||
s = self.sessions.get(uid)
|
||||
|
||||
parts = (update.message.text or "").split("|", 1)
|
||||
s.media_file_id = parts[0].strip()
|
||||
s.text = parts[1].strip() if len(parts) > 1 else None
|
||||
|
||||
await update.message.reply_text("Добавить кнопки? Пришлите строки: Текст|URL. Или '-' чтобы пропустить.")
|
||||
return States.EDIT_KEYBOARD
|
||||
|
||||
# ---------- Работа со списком шаблонов (кнопка) ----------
|
||||
|
||||
async def choose_template_open(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
context.user_data["tpl_page"] = 0
|
||||
return await self._render_tpl_list(q, uid, page=0)
|
||||
|
||||
async def choose_template_navigate(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
page = int(q.data.split(":", 1)[1])
|
||||
context.user_data["tpl_page"] = page
|
||||
return await self._render_tpl_list(q, uid, page)
|
||||
|
||||
async def _render_tpl_list(self, q_or_msg, uid: int, page: int):
|
||||
total = await count_templates(uid)
|
||||
items = await list_templates(uid, limit=PAGE_SIZE, offset=page * PAGE_SIZE)
|
||||
if not items:
|
||||
text = "Шаблонов пока нет. Создай через /tpl_new."
|
||||
if hasattr(q_or_msg, "edit_message_text"):
|
||||
await q_or_msg.edit_message_text(text)
|
||||
else:
|
||||
await q_or_msg.reply_text(text)
|
||||
return States.ENTER_TEXT
|
||||
|
||||
markup = KbBuilder.templates_list(items, page, total, PAGE_SIZE)
|
||||
text = f"Шаблоны (стр. {page+1}/{(total-1)//PAGE_SIZE + 1}):"
|
||||
if hasattr(q_or_msg, "edit_message_text"):
|
||||
await q_or_msg.edit_message_text(text, reply_markup=markup)
|
||||
else:
|
||||
await q_or_msg.reply_text(text, reply_markup=markup)
|
||||
return States.SELECT_TEMPLATE
|
||||
|
||||
async def choose_template_apply(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
name = q.data.split(":", 1)[1]
|
||||
|
||||
tpl_meta = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||
required = set(tpl_meta.get("_required", []))
|
||||
if required:
|
||||
context.user_data["preview"] = {"name": name, "provided": {}, "missing": sorted(list(required))}
|
||||
await q.edit_message_text(
|
||||
"Шаблон требует переменные: " + ", ".join(sorted(list(required))) +
|
||||
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||
)
|
||||
return States.PREVIEW_VARS
|
||||
|
||||
return await self._apply_template_and_confirm(q, uid, name, {})
|
||||
|
||||
async def choose_template_preview(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
name = q.data.split(":", 1)[1]
|
||||
meta = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||
required = set(meta.get("_required", []))
|
||||
context.user_data["preview"] = {"name": name, "provided": {}, "missing": sorted(list(required))}
|
||||
if required:
|
||||
await q.edit_message_text(
|
||||
"Для предпросмотра нужны переменные: " + ", ".join(sorted(list(required))) +
|
||||
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||
)
|
||||
return States.PREVIEW_VARS
|
||||
|
||||
return await self._render_preview_and_confirm(q, uid, name, {})
|
||||
|
||||
async def choose_template_cancel(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
await q.edit_message_text("Отправь текст сообщения или введи #имя для шаблона.")
|
||||
return States.ENTER_TEXT
|
||||
|
||||
# ---------- Предпросмотр: сбор переменных / подтверждение ----------
|
||||
|
||||
async def preview_collect_vars(self, update: Update, context: CallbackContext):
|
||||
uid = update.effective_user.id
|
||||
data = context.user_data.get("preview", {})
|
||||
provided: Dict[str, str] = dict(data.get("provided", {}))
|
||||
missing = set(data.get("missing", []))
|
||||
|
||||
provided.update(MessageParsers.parse_key_value_lines(update.message.text))
|
||||
still_missing = sorted(list(missing - set(provided.keys())))
|
||||
if still_missing:
|
||||
context.user_data["preview"] = {"name": data.get("name"), "provided": provided, "missing": still_missing}
|
||||
await update.message.reply_text(
|
||||
"Ещё не хватает: " + ", ".join(still_missing) +
|
||||
"\nПришли оставшиеся значения в формате key=value."
|
||||
)
|
||||
return States.PREVIEW_VARS
|
||||
|
||||
context.user_data["preview"] = {"name": data.get("name"), "provided": provided, "missing": []}
|
||||
return await self._render_preview_and_confirm(update.message, uid, data.get("name"), provided)
|
||||
|
||||
async def preview_confirm(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
action = q.data.split(":", 1)[1]
|
||||
data = context.user_data.get("preview", {})
|
||||
name = data.get("name")
|
||||
provided = data.get("provided", {})
|
||||
|
||||
if action == "use":
|
||||
return await self._apply_template_and_confirm(q, uid, name, provided)
|
||||
|
||||
# edit variables
|
||||
meta = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||
required = set(meta.get("_required", []))
|
||||
missing = sorted(list(required - set(provided.keys())))
|
||||
if missing:
|
||||
await q.edit_message_text(
|
||||
"Ещё требуются: " + ", ".join(missing) +
|
||||
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||
)
|
||||
else:
|
||||
await q.edit_message_text("Измени значения переменных и пришли заново (key=value ...).")
|
||||
context.user_data["preview"] = {"name": name, "provided": provided, "missing": missing}
|
||||
return States.PREVIEW_VARS
|
||||
|
||||
# ---------- Редактор клавиатуры / подтверждение отправки ----------
|
||||
|
||||
async def edit_keyboard(self, update: Update, context: CallbackContext):
|
||||
uid = update.effective_user.id
|
||||
s = self.sessions.get(uid)
|
||||
raw = (update.message.text or "").strip()
|
||||
|
||||
if raw == "-":
|
||||
s.keyboard = None
|
||||
else:
|
||||
buttons = []
|
||||
for line in raw.splitlines():
|
||||
if "|" in line:
|
||||
t, u = line.split("|", 1)
|
||||
buttons.append((t.strip(), u.strip()))
|
||||
keyboard_rows = [[{"text": t, "url": u}] for t, u in buttons] if buttons else None
|
||||
s.keyboard = {"rows": keyboard_rows} if keyboard_rows else None
|
||||
|
||||
await update.message.reply_text("Как публикуем?", reply_markup=KbBuilder.send_confirm())
|
||||
return States.CONFIRM_SEND
|
||||
|
||||
async def confirm_send(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
action = q.data.split(":", 1)[1]
|
||||
if action == "now":
|
||||
await self._dispatch_now(uid, q)
|
||||
self.sessions.drop(uid)
|
||||
return -1
|
||||
else:
|
||||
await q.edit_message_text("Укажи время в формате YYYY-MM-DD HH:MM (Asia/Seoul)")
|
||||
return States.ENTER_SCHEDULE
|
||||
|
||||
async def enter_schedule(self, update: Update, context: CallbackContext):
|
||||
uid = update.effective_user.id
|
||||
when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M")
|
||||
await self._dispatch_with_eta(uid, when)
|
||||
await update.message.reply_text("Задача запланирована.")
|
||||
self.sessions.drop(uid)
|
||||
return -1
|
||||
|
||||
# ---------- Создание/удаление шаблонов ----------
|
||||
|
||||
async def tpl_new_start(self, update: Update, context: CallbackContext):
|
||||
uid = update.effective_user.id
|
||||
context.user_data["tpl"] = {"owner_id": uid}
|
||||
await update.message.reply_text("Создание шаблона. Введи короткое имя (латиница/цифры), которым будешь вызывать: #имя")
|
||||
return States.TPL_NEW_NAME
|
||||
|
||||
async def tpl_new_name(self, update: Update, context: CallbackContext):
|
||||
name = (update.message.text or "").strip()
|
||||
context.user_data["tpl"]["name"] = name
|
||||
await update.message.reply_text("Выбери тип шаблона:", reply_markup=KbBuilder.tpl_types())
|
||||
return States.TPL_NEW_TYPE
|
||||
|
||||
async def tpl_new_type(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
t = q.data.split(":", 1)[1]
|
||||
context.user_data["tpl"]["type"] = t
|
||||
await q.edit_message_text("Выбери формат разметки для этого шаблона:", reply_markup=KbBuilder.tpl_formats())
|
||||
return States.TPL_NEW_FORMAT
|
||||
|
||||
async def tpl_new_format(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
fmt = q.data.split(":", 1)[1]
|
||||
context.user_data["tpl"]["parse_mode"] = fmt
|
||||
await q.edit_message_text(
|
||||
"Введи Jinja2‑шаблон текста.\n\n"
|
||||
"Если выбрал MarkdownV2, экранируй пользовательские значения фильтром {{ var|mdv2 }}.\n"
|
||||
"Для HTML используй {{ var|html }} при необходимости."
|
||||
)
|
||||
return States.TPL_NEW_CONTENT
|
||||
|
||||
async def tpl_new_content(self, update: Update, context: CallbackContext):
|
||||
context.user_data["tpl"]["content"] = update.message.text
|
||||
await update.message.reply_text(
|
||||
"Добавь кнопки (по желанию). Пришли строки вида: Текст|URL, можно несколько строк. Либо '-' чтобы пропустить."
|
||||
)
|
||||
return States.TPL_NEW_KB
|
||||
|
||||
async def tpl_new_kb(self, update: Update, context: CallbackContext):
|
||||
uid = update.effective_user.id
|
||||
raw = (update.message.text or "").strip()
|
||||
kb_tpl = None if raw == "-" else self._parse_kb_lines(raw)
|
||||
data = context.user_data.get("tpl", {})
|
||||
parse_mode = data.get("parse_mode") or "HTML"
|
||||
|
||||
tpl_id = await create_template(
|
||||
owner_id=uid,
|
||||
name=data.get("name"),
|
||||
title=None,
|
||||
type_=data.get("type"),
|
||||
content=data.get("content"),
|
||||
keyboard_tpl=kb_tpl,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
req = required_variables_of_template(data.get("content") or "", kb_tpl or [])
|
||||
extra = f"\nТребуемые переменные: {', '.join(sorted(req))}" if req else ""
|
||||
await update.message.reply_text(f"Шаблон сохранён (id={tpl_id}). Используй: #{data.get('name')} key=value ...{extra}")
|
||||
context.user_data.pop("tpl", None)
|
||||
return -1
|
||||
|
||||
async def tpl_list(self, update: Update, context: CallbackContext):
|
||||
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
|
||||
|
||||
async def tpl_delete_ok(self, update: Update, context: CallbackContext):
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
tpl_id = int(q.data.split(":", 1)[1])
|
||||
ok = await delete_template(owner_id=uid, tpl_id=tpl_id)
|
||||
await q.edit_message_text("Удалено" if ok else "Не найдено")
|
||||
return -1
|
||||
|
||||
# ---------- Внутренние утилиты ----------
|
||||
|
||||
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"
|
||||
|
||||
if hasattr(q_or_msg, "edit_message_text"):
|
||||
await q_or_msg.edit_message_text(f"Предпросмотр:\n\n{text[:3500]}", parse_mode=parse_mode)
|
||||
else:
|
||||
await q_or_msg.reply_text(f"Предпросмотр:\n\n{text[:3500]}", parse_mode=parse_mode)
|
||||
|
||||
# кнопки отдельным сообщением при необходимости
|
||||
if hasattr(q_or_msg, "message") and q_or_msg.message:
|
||||
await q_or_msg.message.reply_text("Что дальше?", reply_markup=KbBuilder.preview_actions())
|
||||
else:
|
||||
# если это message, просто второй месседж
|
||||
if not hasattr(q_or_msg, "edit_message_text"):
|
||||
await q_or_msg.reply_text("Что дальше?", reply_markup=KbBuilder.preview_actions())
|
||||
return States.PREVIEW_CONFIRM
|
||||
|
||||
async def _apply_template_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)
|
||||
s = self.sessions.get(uid)
|
||||
s.type = rendered["type"]
|
||||
s.text = rendered["text"]
|
||||
s.keyboard = {"rows": rendered["keyboard_rows"]} if rendered["keyboard_rows"] else None
|
||||
s.parse_mode = rendered.get("parse_mode") or s.parse_mode or "HTML"
|
||||
|
||||
if hasattr(q_or_msg, "edit_message_text"):
|
||||
await q_or_msg.edit_message_text("Шаблон применён. Как публикуем?", reply_markup=KbBuilder.send_confirm())
|
||||
else:
|
||||
await q_or_msg.reply_text("Шаблон применён. Как публикуем?", reply_markup=KbBuilder.send_confirm())
|
||||
return States.CONFIRM_SEND
|
||||
|
||||
async def _dispatch_now(self, uid: int, qmsg):
|
||||
s = self.sessions.get(uid)
|
||||
if not s or not s.channel_id:
|
||||
await qmsg.edit_message_text("Сессия потеряна.")
|
||||
return
|
||||
token = settings.editor_bot_token
|
||||
payload = build_payload(
|
||||
ptype=s.type,
|
||||
text=s.text,
|
||||
media_file_id=s.media_file_id,
|
||||
parse_mode=s.parse_mode or "HTML",
|
||||
keyboard=s.keyboard,
|
||||
)
|
||||
send_post_task.delay(token, s.channel_id, payload)
|
||||
await qmsg.edit_message_text("Отправка запущена.")
|
||||
|
||||
async def _dispatch_with_eta(self, uid: int, when: datetime):
|
||||
s = self.sessions.get(uid)
|
||||
token = settings.editor_bot_token
|
||||
payload = build_payload(
|
||||
ptype=s.type,
|
||||
text=s.text,
|
||||
media_file_id=s.media_file_id,
|
||||
parse_mode=s.parse_mode or "HTML",
|
||||
keyboard=s.keyboard,
|
||||
)
|
||||
send_post_task.apply_async(args=[token, s.channel_id, payload], eta=when)
|
||||
|
||||
@staticmethod
|
||||
def _parse_kb_lines(raw: str) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
for line in raw.splitlines():
|
||||
if "|" in line:
|
||||
t, u = line.split("|", 1)
|
||||
rows.append({"text": t.strip(), "url": u.strip()})
|
||||
return rows
|
||||
589
app/bots/editor_bot.py
Normal file
589
app/bots/editor_bot.py
Normal file
@@ -0,0 +1,589 @@
|
||||
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
|
||||
|
||||
# -------- Вспомогательные функции для шаблонов ---------
|
||||
|
||||
async def _render_tpl_list(q_or_msg: Update | CallbackContext, uid: int, page: int) -> int:
|
||||
"""Отображение списка шаблонов с пагинацией."""
|
||||
try:
|
||||
total = await count_templates(uid)
|
||||
offset = page * PAGE_SIZE
|
||||
tpls = await list_templates(uid, limit=PAGE_SIZE, offset=offset)
|
||||
|
||||
if not tpls:
|
||||
if page == 0:
|
||||
if hasattr(q_or_msg, "edit_message_text"):
|
||||
await q_or_msg.edit_message_text("Шаблонов пока нет. Создай через /tpl_new.")
|
||||
else:
|
||||
await q_or_msg.reply_text("Шаблонов пока нет. Создай через /tpl_new.")
|
||||
return ENTER_TEXT
|
||||
else:
|
||||
return await _render_tpl_list(q_or_msg, uid, 0)
|
||||
|
||||
kb = []
|
||||
for t in tpls:
|
||||
kb.append([
|
||||
InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"),
|
||||
InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}")
|
||||
])
|
||||
|
||||
nav = []
|
||||
if page > 0:
|
||||
nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}"))
|
||||
if offset + PAGE_SIZE < total:
|
||||
nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}"))
|
||||
if nav:
|
||||
kb.append(nav)
|
||||
kb.append([InlineKeyboardButton("Отмена", callback_data="tpl:cancel")])
|
||||
|
||||
text = f"Шаблоны (стр. {page+1}/{(total-1)//PAGE_SIZE + 1}):"
|
||||
if hasattr(q_or_msg, "edit_message_text"):
|
||||
await q_or_msg.edit_message_text(text, reply_markup=InlineKeyboardMarkup(kb))
|
||||
else:
|
||||
await q_or_msg.reply_text(text, reply_markup=InlineKeyboardMarkup(kb))
|
||||
return SELECT_TEMPLATE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering template list: {e}")
|
||||
if hasattr(q_or_msg, "edit_message_text"):
|
||||
await q_or_msg.edit_message_text("Ошибка при загрузке списка шаблонов")
|
||||
else:
|
||||
await q_or_msg.reply_text("Ошибка при загрузке списка шаблонов")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def _apply_template_and_confirm(q_or_msg: Union[CallbackQuery, Message], uid: int, name: str, ctx_vars: dict) -> int:
|
||||
"""Применение шаблона к текущему посту."""
|
||||
try:
|
||||
rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars)
|
||||
session[uid].update({
|
||||
"type": rendered["type"],
|
||||
"text": rendered["text"],
|
||||
"keyboard": {"rows": rendered["keyboard_rows"]} if rendered.get("keyboard_rows") else None,
|
||||
"parse_mode": rendered.get("parse_mode") or session[uid].get("parse_mode") or "HTML"
|
||||
})
|
||||
|
||||
kb = [
|
||||
[InlineKeyboardButton("Отправить сейчас", callback_data="send:now")],
|
||||
[InlineKeyboardButton("Запланировать", callback_data="send:schedule")]
|
||||
]
|
||||
markup = InlineKeyboardMarkup(kb)
|
||||
|
||||
if isinstance(q_or_msg, CallbackQuery):
|
||||
await q_or_msg.edit_message_text(
|
||||
"Шаблон применён. Как публикуем?",
|
||||
reply_markup=markup
|
||||
)
|
||||
else:
|
||||
await cast(Message, q_or_msg).reply_text(
|
||||
"Шаблон применён. Как публикуем?",
|
||||
reply_markup=markup
|
||||
)
|
||||
|
||||
return CONFIRM_SEND
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying template: {e}")
|
||||
if isinstance(q_or_msg, CallbackQuery):
|
||||
await q_or_msg.edit_message_text("Ошибка при применении шаблона")
|
||||
else:
|
||||
await cast(Message, q_or_msg).reply_text("Ошибка при применении шаблона")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def _render_preview_and_confirm(q_or_msg: Update | CallbackContext, uid: int, name: str, ctx_vars: dict) -> int:
|
||||
"""Рендеринг предпросмотра шаблона."""
|
||||
try:
|
||||
rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars)
|
||||
text = rendered["text"]
|
||||
parse_mode = rendered.get("parse_mode") or session.get(uid, {}).get("parse_mode") or "HTML"
|
||||
|
||||
preview_text = f"Предпросмотр:\n\n{text[:3500]}"
|
||||
|
||||
if hasattr(q_or_msg, "edit_message_text"):
|
||||
await q_or_msg.edit_message_text(preview_text, parse_mode=parse_mode)
|
||||
else:
|
||||
await q_or_msg.reply_text(preview_text, parse_mode=parse_mode)
|
||||
|
||||
kb = [
|
||||
[InlineKeyboardButton("✅ Использовать", callback_data="pv:use")],
|
||||
[InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")],
|
||||
[InlineKeyboardButton("Отмена", callback_data="tpl:cancel")]
|
||||
]
|
||||
markup = InlineKeyboardMarkup(kb)
|
||||
|
||||
if hasattr(q_or_msg, "reply_text"):
|
||||
await q_or_msg.reply_text("Что дальше?", reply_markup=markup)
|
||||
elif hasattr(q_or_msg, "message") and q_or_msg.message:
|
||||
await q_or_msg.message.reply_text("Что дальше?", reply_markup=markup)
|
||||
|
||||
return PREVIEW_CONFIRM
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in preview render: {e}")
|
||||
if hasattr(q_or_msg, "edit_message_text"):
|
||||
await q_or_msg.edit_message_text("Ошибка при рендеринге шаблона")
|
||||
else:
|
||||
await q_or_msg.reply_text("Ошибка при рендеринге шаблона")
|
||||
return ConversationHandler.END
|
||||
|
||||
# -------- Обработчики шаблонов ---------
|
||||
|
||||
async def choose_template_open(update: Update, context: CallbackContext) -> int:
|
||||
"""Открытие списка шаблонов."""
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
context.user_data["tpl_page"] = 0
|
||||
return await _render_tpl_list(q, uid, page=0)
|
||||
|
||||
async def choose_template_navigate(update: Update, context: CallbackContext) -> int:
|
||||
"""Навигация по списку шаблонов."""
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
_, page_s = q.data.split(":")
|
||||
page = int(page_s)
|
||||
context.user_data["tpl_page"] = page
|
||||
return await _render_tpl_list(q, uid, page)
|
||||
|
||||
async def choose_template_apply(update: Update, context: CallbackContext) -> int:
|
||||
"""Применение выбранного шаблона."""
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
try:
|
||||
name = q.data.split(":")[1]
|
||||
tpl = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||
required = set(tpl.get("_required", []))
|
||||
if required:
|
||||
context.user_data["preview"] = {"name": name, "provided": {}, "missing": list(required)}
|
||||
await q.edit_message_text(
|
||||
"Шаблон требует переменные: " + ", ".join(sorted(required)) +
|
||||
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||
)
|
||||
return PREVIEW_VARS
|
||||
return await _apply_template_and_confirm(q, uid, name, {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying template: {e}")
|
||||
await q.edit_message_text("Ошибка при применении шаблона")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def choose_template_preview(update: Update, context: CallbackContext) -> int:
|
||||
"""Предпросмотр шаблона."""
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
uid = update.effective_user.id
|
||||
try:
|
||||
name = q.data.split(":")[1]
|
||||
tpl = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||
required = set(tpl.get("_required", []))
|
||||
context.user_data["preview"] = {"name": name, "provided": {}, "missing": list(required)}
|
||||
if required:
|
||||
await q.edit_message_text(
|
||||
"Для предпросмотра нужны переменные: " + ", ".join(sorted(required)) +
|
||||
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||
)
|
||||
return PREVIEW_VARS
|
||||
return await _render_preview_and_confirm(q, uid, name, {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error previewing template: {e}")
|
||||
await q.edit_message_text("Ошибка при предпросмотре шаблона")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def choose_template_cancel(update: Update, context: CallbackContext) -> int:
|
||||
"""Отмена выбора шаблона."""
|
||||
q = update.callback_query
|
||||
await q.answer()
|
||||
await q.edit_message_text("Отправь текст сообщения или введи #имя для шаблона.")
|
||||
return ENTER_TEXT
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
395
app/bots/editor_bot.py.new
Normal file
395
app/bots/editor_bot.py.new
Normal file
@@ -0,0 +1,395 @@
|
||||
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()
|
||||
15
app/bots/session_manager.py
Normal file
15
app/bots/session_manager.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
class SessionManager:
|
||||
def __init__(self):
|
||||
self._sessions: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
def get_session(self, user_id: int) -> Dict[str, Any]:
|
||||
return self._sessions.setdefault(user_id, {})
|
||||
|
||||
def update_session(self, user_id: int, data: Dict[str, Any]) -> None:
|
||||
self._sessions[user_id] = {**self.get_session(user_id), **data}
|
||||
|
||||
def clear_session(self, user_id: int) -> None:
|
||||
self._sessions.pop(user_id, None)
|
||||
18
app/bots/states/base.py
Normal file
18
app/bots/states/base.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import IntEnum
|
||||
from telegram import Update
|
||||
from telegram.ext import CallbackContext
|
||||
|
||||
class State(ABC):
|
||||
@abstractmethod
|
||||
async def handle(self, update: Update, context: CallbackContext) -> int:
|
||||
pass
|
||||
|
||||
class BotStates(IntEnum):
|
||||
CHOOSE_CHANNEL = 0
|
||||
CHOOSE_TYPE = 1
|
||||
ENTER_TEXT = 2
|
||||
ENTER_MEDIA = 3
|
||||
EDIT_KEYBOARD = 4
|
||||
CONFIRM_SEND = 5
|
||||
ENTER_SCHEDULE = 6
|
||||
Reference in New Issue
Block a user