commit 55920145309e9a8c51ee54348e4eefb17a0733e8 Author: Andrey K. Choi Date: Sun Aug 17 11:44:54 2025 +0900 init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1919d80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,192 @@ +<<<<<<< HEAD +======= +<<<<<<< HEAD +>>>>>>> bddb72c (init commit) +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +<<<<<<< HEAD +======= +======= +.env +.venv/ +.history/ +__pycache__ +*.log +*.sqllite3 +*.db +>>>>>>> a3e64df (init commit) +>>>>>>> bddb72c (init commit) diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2f69a0 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# postbot + diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..fe839b3 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/main.py b/app/api/main.py new file mode 100644 index 0000000..9a32e8f --- /dev/null +++ b/app/api/main.py @@ -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} \ No newline at end of file diff --git a/app/api/routes/templates.py b/app/api/routes/templates.py new file mode 100644 index 0000000..98862e8 --- /dev/null +++ b/app/api/routes/templates.py @@ -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} diff --git a/app/api/schemas/__init__.py b/app/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/base.py b/app/api/schemas/base.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/keyboard.py b/app/api/schemas/keyboard.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/post.py b/app/api/schemas/post.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/schemas/template.py b/app/api/schemas/template.py new file mode 100644 index 0000000..f644b06 --- /dev/null +++ b/app/api/schemas/template.py @@ -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) \ No newline at end of file diff --git a/app/bots/__init__.py b/app/bots/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bots/editor/__init__.py b/app/bots/editor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bots/editor/keyboards.py b/app/bots/editor/keyboards.py new file mode 100644 index 0000000..edc3253 --- /dev/null +++ b/app/bots/editor/keyboards.py @@ -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) diff --git a/app/bots/editor/messages.py b/app/bots/editor/messages.py new file mode 100644 index 0000000..609b375 --- /dev/null +++ b/app/bots/editor/messages.py @@ -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 diff --git a/app/bots/editor/oop_app.py b/app/bots/editor/oop_app.py new file mode 100644 index 0000000..e6ea851 --- /dev/null +++ b/app/bots/editor/oop_app.py @@ -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() diff --git a/app/bots/editor/session.py b/app/bots/editor/session.py new file mode 100644 index 0000000..266351f --- /dev/null +++ b/app/bots/editor/session.py @@ -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] diff --git a/app/bots/editor/states.py b/app/bots/editor/states.py new file mode 100644 index 0000000..c377995 --- /dev/null +++ b/app/bots/editor/states.py @@ -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 diff --git a/app/bots/editor/wizard.py b/app/bots/editor/wizard.py new file mode 100644 index 0000000..8238ce5 --- /dev/null +++ b/app/bots/editor/wizard.py @@ -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 diff --git a/app/bots/editor_bot.py b/app/bots/editor_bot.py new file mode 100644 index 0000000..7d286f4 --- /dev/null +++ b/app/bots/editor_bot.py @@ -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() diff --git a/app/bots/editor_bot.py.new b/app/bots/editor_bot.py.new new file mode 100644 index 0000000..b5e45d3 --- /dev/null +++ b/app/bots/editor_bot.py.new @@ -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() diff --git a/app/bots/session_manager.py b/app/bots/session_manager.py new file mode 100644 index 0000000..c69fdc7 --- /dev/null +++ b/app/bots/session_manager.py @@ -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) \ No newline at end of file diff --git a/app/bots/states/base.py b/app/bots/states/base.py new file mode 100644 index 0000000..882f6a1 --- /dev/null +++ b/app/bots/states/base.py @@ -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 \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..cfdd0e5 --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..bcd36c2 --- /dev/null +++ b/app/db/base.py @@ -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 diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..d797b7d --- /dev/null +++ b/app/db/session.py @@ -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 \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/audit.py b/app/models/audit.py new file mode 100644 index 0000000..482cf49 --- /dev/null +++ b/app/models/audit.py @@ -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()) diff --git a/app/models/bot.py b/app/models/bot.py new file mode 100644 index 0000000..eacb81c --- /dev/null +++ b/app/models/bot.py @@ -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") diff --git a/app/models/channel.py b/app/models/channel.py new file mode 100644 index 0000000..1697b50 --- /dev/null +++ b/app/models/channel.py @@ -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) diff --git a/app/models/keyboard.py b/app/models/keyboard.py new file mode 100644 index 0000000..d2ba306 --- /dev/null +++ b/app/models/keyboard.py @@ -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) diff --git a/app/models/post.py b/app/models/post.py new file mode 100644 index 0000000..d2931c4 --- /dev/null +++ b/app/models/post.py @@ -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) diff --git a/app/models/templates.py b/app/models/templates.py new file mode 100644 index 0000000..7bbbde4 --- /dev/null +++ b/app/models/templates.py @@ -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()) \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..5feec51 --- /dev/null +++ b/app/models/user.py @@ -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"" \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/crypto.py b/app/services/crypto.py new file mode 100644 index 0000000..cb3a8eb --- /dev/null +++ b/app/services/crypto.py @@ -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] \ No newline at end of file diff --git a/app/services/telegram.py b/app/services/telegram.py new file mode 100644 index 0000000..ad442f1 --- /dev/null +++ b/app/services/telegram.py @@ -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, + } \ No newline at end of file diff --git a/app/services/templates.py b/app/services/templates.py new file mode 100644 index 0000000..212037e --- /dev/null +++ b/app/services/templates.py @@ -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 [])), + } \ No newline at end of file diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tasks/celery_app.py b/app/tasks/celery_app.py new file mode 100644 index 0000000..5ea226e --- /dev/null +++ b/app/tasks/celery_app.py @@ -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", +) \ No newline at end of file diff --git a/app/tasks/senders.py b/app/tasks/senders.py new file mode 100644 index 0000000..319f586 --- /dev/null +++ b/app/tasks/senders.py @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..deee9df --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,84 @@ +services: + db: + image: mariadb:11.6 + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MARIADB_DATABASE: ${DB_NAME} + MARIADB_USER: ${DB_USER} + MARIADB_PASSWORD: ${DB_PASSWORD} + volumes: + - db_data:/var/lib/mysql + env_file: .env + ports: + - "3306:3306" + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u\"$DB_USER\" -p\"$DB_PASSWORD\" --silent || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 30s + restart: unless-stopped + + redis: + image: redis:7-alpine + env_file: .env + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + restart: unless-stopped + + + api: + build: + context: . + dockerfile: docker/Dockerfile.api + env_file: .env + environment: + RUN_MIGRATIONS: "true" + depends_on: + db: + condition: service_healthy + ports: ["8000:8000"] + restart: unless-stopped + + editor-bot: + build: + context: . + dockerfile: docker/Dockerfile.bot + env_file: .env + environment: + RUN_MIGRATIONS: "false" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + worker: + build: + context: . + dockerfile: docker/Dockerfile.worker + env_file: .env + environment: + RUN_MIGRATIONS: "false" + depends_on: + redis: + condition: service_healthy + db: + condition: service_healthy + restart: unless-stopped + + beat: + build: + context: . + dockerfile: docker/Dockerfile.beat + env_file: .env + environment: + RUN_MIGRATIONS: "false" + depends_on: + - worker + restart: unless-stopped + +volumes: + db_data: \ No newline at end of file diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api new file mode 100644 index 0000000..7455ebd --- /dev/null +++ b/docker/Dockerfile.api @@ -0,0 +1,20 @@ +FROM python:3.12-slim +ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libmariadb-dev \ + mariadb-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# нормализуем CRLF и даём +x +RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh + +ENTRYPOINT ["bash", "/app/docker/entrypoint.sh"] +CMD ["uvicorn", "app.api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base new file mode 100644 index 0000000..bbdf5fc --- /dev/null +++ b/docker/Dockerfile.base @@ -0,0 +1,28 @@ +FROM python:3.12-slim AS base +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 +WORKDIR /app + +# Системные зависимости (MariaDB client headers для asyncmy/pymysql) +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libmariadb-dev \ + mariadb-client \ + && rm -rf /var/lib/apt/lists/* + +# Устанавливаем зависимости проекта +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем исходники +COPY . . + +RUN apt-get update && apt-get install -y --no-install-recommends bash && rm -rf /var/lib/apt/lists/* +RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh + +# В рантайме передаются переменные окружения из compose/.env +ENV TZ=Asia/Seoul +ENV DATABASE_URL="" +ENV DATABASE_URL_SYNC="" +ENV CELERY_BROKER_URL="" +ENV CELERY_RESULT_BACKEND="" \ No newline at end of file diff --git a/docker/Dockerfile.beat b/docker/Dockerfile.beat new file mode 100644 index 0000000..0017fd2 --- /dev/null +++ b/docker/Dockerfile.beat @@ -0,0 +1,19 @@ +FROM python:3.12-slim +ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libmariadb-dev \ + mariadb-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +RUN apt-get update && apt-get install -y --no-install-recommends bash && rm -rf /var/lib/apt/lists/* +RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh + +ENTRYPOINT ["/app/docker/entrypoint.sh"] +CMD ["celery", "-A", "app.tasks.celery_app.celery_app", "beat", "-l", "INFO"] \ No newline at end of file diff --git a/docker/Dockerfile.bot b/docker/Dockerfile.bot new file mode 100644 index 0000000..6468e4e --- /dev/null +++ b/docker/Dockerfile.bot @@ -0,0 +1,19 @@ +FROM python:3.12-slim +ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libmariadb-dev \ + mariadb-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +RUN apt-get update && apt-get install -y --no-install-recommends bash && rm -rf /var/lib/apt/lists/* +RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh + +ENTRYPOINT ["/app/docker/entrypoint.sh"] +CMD ["python", "-m", "app.bots.editor_bot"] \ No newline at end of file diff --git a/docker/Dockerfile.worker b/docker/Dockerfile.worker new file mode 100644 index 0000000..6be2757 --- /dev/null +++ b/docker/Dockerfile.worker @@ -0,0 +1,18 @@ +FROM python:3.12-slim +ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libmariadb-dev \ + mariadb-client \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +RUN apt-get update && apt-get install -y --no-install-recommends bash && rm -rf /var/lib/apt/lists/* +RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh + +ENTRYPOINT ["/app/docker/entrypoint.sh"] +CMD ["celery", "-A", "app.tasks.celery_app.celery_app", "worker", "-l", "INFO"] \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..d01b7eb --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[entrypoint] Waiting for MariaDB at ${DB_HOST:-db}:${DB_PORT:-3306}..." +until mariadb -h "${DB_HOST:-db}" -P "${DB_PORT:-3306}" -u"${DB_USER:-root}" -p"${DB_PASSWORD:-}" -e "SELECT 1" >/dev/null 2>&1; do + sleep 1 +done +echo "[entrypoint] MariaDB is up" + +if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then + if [ -f "/app/alembic.ini" ] && [ -d "/app/migrations" ]; then + echo "[entrypoint] Running alembic upgrade head" + alembic upgrade head || echo "[entrypoint] Alembic failed (non-fatal)" + else + echo "[entrypoint] Skipping alembic — no /app/migrations or alembic.ini" + fi +fi + +echo "[entrypoint] Starting: $*" +exec "$@" diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..d197f76 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,87 @@ +# migrations/env.py +import os +from logging.config import fileConfig +from dotenv import load_dotenv +from alembic import context +from sqlalchemy import create_engine, pool + +# 1) Берём Base из session.py +from app.db.session import Base + +load_dotenv() + +# 2) Импортируем сборщик моделей, чтобы metadata увидело всё +import app.db.base # noqa: F401 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def build_sync_url() -> str: + """ + Возвращает sync-URL для Alembic. + Приоритет: + 1) DATABASE_URL_SYNC (полная строка) + 2) Конструктор из DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD + """ + env_url = os.getenv("DATABASE_URL_SYNC") + if env_url and "://" in env_url: + return env_url + + host = os.getenv("DB_HOST") + port = os.getenv("DB_PORT", "3306") + name = os.getenv("DB_NAME") + user = os.getenv("DB_USER") + pwd = os.getenv("DB_PASSWORD") + + if host and name and user is not None and pwd is not None: + # ВАЖНО: для Alembic нужна sync-строка с PyMySQL + return f"mariadb+pymysql://{user}:{pwd}@{host}:{port}/{name}" + + raise RuntimeError( + "No valid sync DB URL. Set DATABASE_URL_SYNC or DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD." + ) + + +def run_migrations_offline() -> None: + url = build_sync_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "pyformat"}, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + url = build_sync_url() + + connectable = create_engine( + url, + future=True, + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d57c113 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "tg-autoposting-platform" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "fastapi", + "uvicorn[standard]", + "SQLAlchemy>=2.0", + "asyncmy", + "alembic", + "pydantic>=2", + "pydantic-settings", + "python-telegram-bot>=21", + "celery[redis]", + "httpx", + "loguru", +] + +[tool.ruff] +line-length = 100 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9b70123 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +fastapi +uvicorn[standard] +SQLAlchemy>=2.0 +alembic +asyncmy +pymysql +alembic +pydantic>=2 +pydantic-settings +python-telegram-bot>=21 +celery[redis] +httpx +loguru +wget +redis +jinja2 \ No newline at end of file diff --git a/test_db.py b/test_db.py new file mode 100644 index 0000000..403f452 --- /dev/null +++ b/test_db.py @@ -0,0 +1,10 @@ +# test_db.py +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# Замените на ваш URL +engine = create_engine("mysql+pymysql://autopost:3ZuanIFHPFLDgxc7CZTMMhjhxf1d4H4P9wU1jT86a8178Pgssh@localhost:3306/autopost") +Session = sessionmaker(bind=engine) +session = Session() +print("Connected successfully!") +session.close() \ No newline at end of file