init commit

This commit is contained in:
2025-08-17 11:44:54 +09:00
commit 5592014530
59 changed files with 3175 additions and 0 deletions

0
app/.gitignore vendored Normal file
View File

0
app/api/__init__.py Normal file
View File

10
app/api/main.py Normal file
View File

@@ -0,0 +1,10 @@
from fastapi import FastAPI
from app.core.config import settings
from app.api.routes import templates as templates_router
app = FastAPI(title="TG Autoposting API", version="0.1.0")
app.include_router(templates_router.router)
@app.get("/health")
async def health():
return {"status": "ok", "env": settings.env}

142
app/api/routes/templates.py Normal file
View File

@@ -0,0 +1,142 @@
# # app/api/routes/templates.py
# from __future__ import annotations
# from fastapi import APIRouter, Depends, HTTPException, Query
# from sqlalchemy import select, or_
# from sqlalchemy.ext.asyncio import AsyncSession
# from app.db.session import get_async_session
# from app.models.templates import Template
# from app.api.schemas.template import TemplateIn, TemplateOut
# from app.services.templates import count_templates
# router = APIRouter(prefix="/templates", tags=["templates"])
# # Заглушка аутентификации
# async def get_owner_id(x_user_id: int | None = None):
# return x_user_id or 0
# @router.post("/", response_model=TemplateOut)
# async def create_tpl(
# data: TemplateIn,
# owner_id: int = Depends(get_owner_id),
# s: AsyncSession = Depends(get_async_session),
# ):
# tpl = Template(owner_id=owner_id, **data.model_dump())
# s.add(tpl)
# await s.commit()
# await s.refresh(tpl)
# return tpl
# @router.get("/", response_model=list[TemplateOut])
# async def list_tpls(
# owner_id: int = Depends(get_owner_id),
# limit: int = Query(20, ge=1, le=100),
# offset: int = Query(0, ge=0),
# q: str | None = Query(default=None),
# s: AsyncSession = Depends(get_async_session),
# ):
# stmt = select(Template).where(
# Template.owner_id == owner_id,
# Template.is_archived.is_(False),
# )
# if q:
# like = f"%{q}%"
# stmt = stmt.where(or_(Template.name.ilike(like), Template.title.ilike(like)))
# stmt = stmt.order_by(Template.updated_at.desc()).limit(limit).offset(offset)
# res = await s.execute(stmt)
# return list(res.scalars())
# @router.get("/count")
# async def count_tpls(
# owner_id: int = Depends(get_owner_id),
# q: str | None = None,
# ):
# total = await count_templates(owner_id, q)
# return {"total": total}
# @router.delete("/{tpl_id}")
# async def delete_tpl(
# tpl_id: int,
# owner_id: int = Depends(get_owner_id),
# s: AsyncSession = Depends(get_async_session),
# ):
# res = await s.execute(select(Template).where(
# Template.id == tpl_id,
# Template.owner_id == owner_id
# ))
# tpl = res.scalars().first()
# if not tpl:
# raise HTTPException(404, "not found")
# await s.delete(tpl)
# await s.commit()
# return {"ok": True}
# app/api/routes/templates.py
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_async_session
from app.models.templates import Template
from app.api.schemas.template import TemplateIn, TemplateOut
from app.services.templates import count_templates
router = APIRouter(prefix="/templates", tags=["templates"])
async def get_owner_id(x_user_id: int | None = None):
return x_user_id or 0
@router.post("/", response_model=TemplateOut)
async def create_tpl(
data: TemplateIn,
owner_id: int = Depends(get_owner_id),
s: AsyncSession = Depends(get_async_session),
):
tpl = Template(owner_id=owner_id, **data.model_dump())
s.add(tpl)
await s.commit()
await s.refresh(tpl)
return tpl
@router.get("/", response_model=list[TemplateOut])
async def list_tpls(
owner_id: int = Depends(get_owner_id),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
q: str | None = Query(default=None),
s: AsyncSession = Depends(get_async_session),
):
stmt = select(Template).where(
Template.owner_id == owner_id,
Template.is_archived.is_(False),
)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(Template.name.ilike(like), Template.title.ilike(like)))
stmt = stmt.order_by(Template.updated_at.desc()).limit(limit).offset(offset)
res = await s.execute(stmt)
return list(res.scalars())
@router.get("/count")
async def count_tpls(owner_id: int = Depends(get_owner_id), q: str | None = None):
total = await count_templates(owner_id, q)
return {"total": total}
@router.delete("/{tpl_id}")
async def delete_tpl(
tpl_id: int,
owner_id: int = Depends(get_owner_id),
s: AsyncSession = Depends(get_async_session),
):
res = await s.execute(select(Template).where(
Template.id == tpl_id,
Template.owner_id == owner_id
))
tpl = res.scalars().first()
if not tpl:
raise HTTPException(404, "not found")
await s.delete(tpl)
await s.commit()
return {"ok": True}

View File

0
app/api/schemas/base.py Normal file
View File

View File

0
app/api/schemas/post.py Normal file
View File

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from typing import Optional, Literal
from pydantic import BaseModel, Field, ConfigDict
class TemplateIn(BaseModel):
name: str = Field(min_length=1, max_length=64)
title: Optional[str] = None
type: Literal["text","photo","video","animation"] = "text"
content: str
keyboard_tpl: Optional[list[dict]] = None
parse_mode: Optional[str] = "HTML"
visibility: Literal["private","org","public"] = "private"
class TemplateOut(TemplateIn):
id: int
is_archived: bool
model_config = ConfigDict(from_attributes=True)

0
app/bots/__init__.py Normal file
View File

View File

View 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)

View 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

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

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

View 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
View 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

0
app/core/__init__.py Normal file
View File

30
app/core/config.py Normal file
View File

@@ -0,0 +1,30 @@
# app/core/config.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# общие
env: str = "dev"
tz: str = "Asia/Seoul"
# БД (async/sync)
database_url: str = Field(alias="DATABASE_URL")
database_url_sync: str | None = Field(default=None, alias="DATABASE_URL_SYNC")
# Redis/Celery
redis_url: str = Field(alias="REDIS_URL")
celery_broker_url: str = Field(alias="CELERY_BROKER_URL")
celery_result_backend: str = Field(alias="CELERY_RESULT_BACKEND")
# Telegram / секреты
editor_bot_token: str = Field(alias="EDITOR_BOT_TOKEN")
secret_key: str | None = Field(default=None, alias="SECRET_KEY")
# Конфиг загрузки
model_config = SettingsConfigDict(
env_file=".env",
extra="ignore", # игнорировать лишние переменные (DB_HOST, DB_USER и т.п.)
case_sensitive=False, # имена переменных без учета регистра
)
settings = Settings()

0
app/core/security.py Normal file
View File

0
app/db/__init__.py Normal file
View File

3
app/db/base.py Normal file
View File

@@ -0,0 +1,3 @@
from app.db.session import Base # импортируем сам Base
# Импортируем все модели, чтобы они зарегистрировались в metadata:
from app.models import user, bot, channel, keyboard, post, audit, templates # noqa: F401

43
app/db/session.py Normal file
View File

@@ -0,0 +1,43 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.core.config import settings
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.database_url,
future=True,
echo=False,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
)
class Base(DeclarativeBase):
"""Единая declarative Base для всех моделей."""
pass
# каноничное имя
async_session_maker = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
# алиас для обратной совместимости (если где-то используется)
AsyncSessionLocal = async_session_maker
# DI-генератор для FastAPI
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
# тоже оставим старое имя-обёртку, если где-то подключено
get_session = get_async_session

0
app/models/__init__.py Normal file
View File

15
app/models/audit.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import ForeignKey, String, JSON, func, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
action: Mapped[str] = mapped_column(String(64))
payload: Mapped[Optional[dict]] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

17
app/models/bot.py Normal file
View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import ForeignKey, String, func, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class Bot(Base):
__tablename__ = "bots"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(64))
username: Mapped[str | None] = mapped_column(String(64))
token_enc: Mapped[str] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
owner = relationship("User")

26
app/models/channel.py Normal file
View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import ForeignKey, String, BigInteger, Boolean, UniqueConstraint, func, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class Channel(Base):
__tablename__ = "channels"
__table_args__ = (UniqueConstraint("owner_id", "chat_id", name="uq_owner_chat"),)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
chat_id: Mapped[int] = mapped_column(BigInteger, index=True)
title: Mapped[str | None] = mapped_column(String(128))
username: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
owner = relationship("User")
class BotChannel(Base):
__tablename__ = "bot_channels"
id: Mapped[int] = mapped_column(primary_key=True)
bot_id: Mapped[int] = mapped_column(ForeignKey("bots.id", ondelete="CASCADE"))
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
can_post: Mapped[bool] = mapped_column(Boolean, default=True)

20
app/models/keyboard.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class Keyboard(Base):
__tablename__ = "keyboards"
id: Mapped[int] = mapped_column(primary_key=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(64))
class KeyboardButton(Base):
__tablename__ = "keyboard_buttons"
id: Mapped[int] = mapped_column(primary_key=True)
keyboard_id: Mapped[int] = mapped_column(ForeignKey("keyboards.id", ondelete="CASCADE"))
text: Mapped[str] = mapped_column(String(128))
url: Mapped[str] = mapped_column(String(512))
order_index: Mapped[int] = mapped_column(Integer, default=0)

52
app/models/post.py Normal file
View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import enum
from datetime import datetime
from typing import Optional
from sqlalchemy import ForeignKey, String, Enum, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class PostType(str, enum.Enum):
text = "text"
photo = "photo"
video = "video"
animation = "animation"
class PostStatus(str, enum.Enum):
draft = "draft"
scheduled = "scheduled"
sent = "sent"
failed = "failed"
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
bot_id: Mapped[Optional[int]] = mapped_column(ForeignKey("bots.id", ondelete="SET NULL"), nullable=True)
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
type: Mapped[PostType] = mapped_column(Enum(PostType))
text: Mapped[Optional[str]] = mapped_column(String(4096))
media_file_id: Mapped[Optional[str]] = mapped_column(String(512))
parse_mode: Mapped[Optional[str]] = mapped_column(String(16))
keyboard_id: Mapped[Optional[int]] = mapped_column(ForeignKey("keyboards.id", ondelete="SET NULL"))
status: Mapped[PostStatus] = mapped_column(Enum(PostStatus), default=PostStatus.draft)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
class ScheduleStatus(str, enum.Enum):
pending = "pending"
done = "done"
cancelled = "cancelled"
class Schedule(Base):
__tablename__ = "schedules"
id: Mapped[int] = mapped_column(primary_key=True)
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"))
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
timezone: Mapped[str] = mapped_column(String(64))
celery_task_id: Mapped[Optional[str]] = mapped_column(String(128))
status: Mapped[ScheduleStatus] = mapped_column(Enum(ScheduleStatus), default=ScheduleStatus.pending)

33
app/models/templates.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Enum, ForeignKey, String, UniqueConstraint, func, Text, JSON, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql.sqltypes import Enum as EnumType # Явный импорт Enum для типизации
from app.db.session import Base
from app.models.post import PostType
from enum import Enum as PyEnum
class TemplateVisibility(str, PyEnum):
private = "private"
org = "org"
public = "public"
class Template(Base):
__tablename__ = "templates"
__table_args__ = (
UniqueConstraint("owner_id", "name", name="uq_template_owner_name"),
)
id: Mapped[int] = mapped_column(primary_key=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(64))
title: Mapped[Optional[str]] = mapped_column(String(128))
type: Mapped[PostType] = mapped_column(EnumType(PostType), default=PostType.text)
content: Mapped[str] = mapped_column(Text)
keyboard_tpl: Mapped[Optional[list[dict]]] = mapped_column(JSON, nullable=True)
parse_mode: Mapped[Optional[str]] = mapped_column(String(16), default="HTML")
visibility: Mapped[TemplateVisibility] = mapped_column(EnumType(TemplateVisibility), default=TemplateVisibility.private)
is_archived: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

17
app/models/user.py Normal file
View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, String, func, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
tg_user_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
username: Mapped[str | None] = mapped_column(String(64))
role: Mapped[str] = mapped_column(String(16), default="user")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
def __repr__(self):
return f"<User(id={self.id}, tg_user_id={self.tg_user_id}, username={self.username}, role={self.role}, created_at={self.created_at})>"

0
app/services/__init__.py Normal file
View File

10
app/services/crypto.py Normal file
View File

@@ -0,0 +1,10 @@
import base64
def encrypt_token(token: str, secret: str) -> str:
# TODO: заменить на реальное шифрование (Fernet) — здесь простая обёртка
raw = f"{secret}:{token}".encode()
return base64.urlsafe_b64encode(raw).decode()
def decrypt_token(token_enc: str, secret: str) -> str:
data = base64.urlsafe_b64decode(token_enc.encode()).decode()
return data.split(":", 1)[1]

19
app/services/telegram.py Normal file
View File

@@ -0,0 +1,19 @@
from typing import Iterable
def make_keyboard_payload(buttons: Iterable[tuple[str, str]] | None):
if not buttons:
return None
rows = [[{"text": t, "url": u}] for t, u in buttons]
return {"rows": rows}
def build_payload(ptype: str, text: str | None, media_file_id: str | None,
parse_mode: str | None, keyboard: dict | None) -> dict:
# ptype: "text" | "photo" | "video" | "animation"
return {
"type": ptype,
"text": text,
"media_file_id": media_file_id,
"parse_mode": parse_mode,
"keyboard": keyboard,
}

141
app/services/templates.py Normal file
View File

@@ -0,0 +1,141 @@
from __future__ import annotations
from typing import Any, Optional, Iterable, Set
from jinja2.sandbox import SandboxedEnvironment
from jinja2 import StrictUndefined, TemplateError, Environment, meta
from sqlalchemy import select
from app.db.session import async_session_maker
from app.models.templates import Template
# ---- Jinja2 окружение для рендера (sandbox) ----
_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
# Безопасные фильтры для подстановок
import html as _py_html
def escape_mdv2(value: str) -> str:
if value is None:
return ""
s = str(value)
for ch in ["_","*","[","]","(",")","~","`",">","#","+","-","=","|","{","}",".","!"]:
s = s.replace(ch, f"\{ch}")
return s
def escape_html(value: str) -> str:
if value is None:
return ""
return _py_html.escape(str(value), quote=False)
_env.filters["mdv2"] = escape_mdv2
_env.filters["html"] = escape_html
_env.filters["upper"] = str.upper
_env.filters["lower"] = str.lower
# ---- Служебное окружение для анализа AST шаблона (поиск переменных) ----
_ast_env = Environment()
# -------- БД операции --------
async def list_templates(owner_id: int, limit: int = 10, offset: int = 0, q: str | None = None) -> list[Template]:
async with async_session_maker() as s:
stmt = select(Template).where(Template.owner_id == owner_id, Template.is_archived.is_(False))
if q:
# простейший поиск по name/title (SQLite/MariaDB — регистр может зависеть от коллаций)
like = f"%{q}%"
from sqlalchemy import or_
stmt = stmt.where(or_(Template.name.ilike(like), Template.title.ilike(like)))
stmt = stmt.order_by(Template.updated_at.desc()).limit(limit).offset(offset)
res = await s.execute(stmt)
return list(res.scalars())
async def count_templates(owner_id: int, q: str | None = None) -> int:
async with async_session_maker() as s:
from sqlalchemy import func, or_
stmt = select(func.count()).select_from(Template).where(Template.owner_id == owner_id, Template.is_archived.is_(False))
if q:
like = f"%{q}%"
stmt = stmt.where(or_(Template.name.ilike(like), Template.title.ilike(like)))
res = await s.execute(stmt)
return int(res.scalar_one())
async def get_template_by_name(owner_id: int, name: str) -> Optional[Template]:
async with async_session_maker() as s:
res = await s.execute(
select(Template)
.where(Template.owner_id == owner_id, Template.name == name, Template.is_archived.is_(False))
.limit(1)
)
return res.scalars().first()
async def create_template(owner_id: int, name: str, title: str | None, type_: str,
content: str, keyboard_tpl: list[dict] | None, parse_mode: str | None) -> int:
async with async_session_maker() as s:
tpl = Template(
owner_id=owner_id,
name=name,
title=title,
type=type_,
content=content,
keyboard_tpl=keyboard_tpl,
parse_mode=parse_mode or "HTML",
)
s.add(tpl)
await s.commit()
await s.refresh(tpl)
return tpl.id
async def delete_template(owner_id: int, tpl_id: int) -> bool:
async with async_session_maker() as s:
res = await s.execute(select(Template).where(Template.id == tpl_id, Template.owner_id == owner_id))
tpl = res.scalars().first()
if not tpl:
return False
await s.delete(tpl)
await s.commit()
return True
# -------- Рендер и анализ переменных --------
def _render_text(src: str, ctx: dict[str, Any]) -> str:
return _env.from_string(src).render(**ctx)
def _render_keyboard(kb_tpl: list[dict] | None, ctx: dict[str, Any]) -> list[list[dict]] | None:
if not kb_tpl:
return None
rows: list[list[dict]] = []
for btn in kb_tpl:
text = _render_text(str(btn.get("text", "")), ctx)
url = _render_text(str(btn.get("url", "")), ctx)
rows.append([{"text": text, "url": url}])
return rows
def _collect_vars_from_str(src: str) -> Set[str]:
try:
ast = _ast_env.parse(src or "")
vars_ = meta.find_undeclared_variables(ast)
# исключим служебные имена (loop, etc.) — обычно они не используются как внешние
return {v for v in vars_ if not v.startswith("loop")}
except Exception:
return set()
def required_variables_of_template(content: str, keyboard_tpl: list[dict] | None) -> Set[str]:
req = _collect_vars_from_str(content)
if keyboard_tpl:
for btn in keyboard_tpl:
req |= _collect_vars_from_str(str(btn.get("text", "")))
req |= _collect_vars_from_str(str(btn.get("url", "")))
return req
async def render_template_by_name(owner_id: int, name: str, ctx: dict[str, Any]) -> dict:
tpl = await get_template_by_name(owner_id, name)
if not tpl:
raise LookupError("Template not found")
content = _render_text(tpl.content, ctx)
keyboard_rows = _render_keyboard(tpl.keyboard_tpl, ctx)
return {
"type": tpl.type,
"text": content,
"parse_mode": tpl.parse_mode,
"keyboard_rows": keyboard_rows,
"_required": list(required_variables_of_template(tpl.content, tpl.keyboard_tpl or [])),
}

0
app/tasks/__init__.py Normal file
View File

15
app/tasks/celery_app.py Normal file
View File

@@ -0,0 +1,15 @@
from celery import Celery
from app.core.config import settings
celery_app = Celery(
"autopost",
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
)
celery_app.conf.update(
timezone=settings.tz,
task_serializer="json",
accept_content=["json"],
result_serializer="json",
)

56
app/tasks/senders.py Normal file
View File

@@ -0,0 +1,56 @@
from celery import shared_task
import httpx
from loguru import logger
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/{method}"
def _build_keyboard(keyboard: dict | None) -> dict | None:
if not keyboard:
return None
rows = []
for row in keyboard.get("rows", []):
rows.append([{"text": btn["text"], "url": btn["url"]} for btn in row])
return {"inline_keyboard": rows}
@shared_task
def send_post_task(token: str, chat_id: int, payload: dict):
"""Отправка поста через Bot API. payload: {type, text, media_file_id, parse_mode, keyboard}
keyboard: {rows: [[{text, url}, ...], ...]}
"""
t = payload.get("type", "text")
reply_markup = _build_keyboard(payload.get("keyboard"))
method = "sendMessage"
data: dict = {"chat_id": chat_id}
if t == "text":
method = "sendMessage"
data.update({"text": payload.get("text", ""), "parse_mode": payload.get("parse_mode")})
elif t == "photo":
method = "sendPhoto"
data.update({"photo": payload.get("media_file_id"), "caption": payload.get("text", ""),
"parse_mode": payload.get("parse_mode")})
elif t == "video":
method = "sendVideo"
data.update({"video": payload.get("media_file_id"), "caption": payload.get("text", ""),
"parse_mode": payload.get("parse_mode")})
elif t == "animation":
method = "sendAnimation"
data.update({"animation": payload.get("media_file_id"), "caption": payload.get("text", ""),
"parse_mode": payload.get("parse_mode")})
if reply_markup:
data["reply_markup"] = reply_markup
url = TELEGRAM_API_BASE.format(token=token, method=method)
with httpx.Client(timeout=30) as client:
r = client.post(url, json=data)
try:
r.raise_for_status()
logger.info("Sent post to {} via {}", chat_id, method)
return r.json()
except httpx.HTTPStatusError as e:
logger.error("Telegram send failed: {} :: {}", e, r.text)
raise