init commit
This commit is contained in:
192
.gitignore
vendored
Normal file
192
.gitignore
vendored
Normal file
@@ -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)
|
||||||
147
alembic.ini
Normal file
147
alembic.ini
Normal file
@@ -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 <script_location>/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
|
||||||
0
app/.gitignore
vendored
Normal file
0
app/.gitignore
vendored
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
10
app/api/main.py
Normal file
10
app/api/main.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.api.routes import templates as templates_router
|
||||||
|
|
||||||
|
app = FastAPI(title="TG Autoposting API", version="0.1.0")
|
||||||
|
app.include_router(templates_router.router)
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "env": settings.env}
|
||||||
142
app/api/routes/templates.py
Normal file
142
app/api/routes/templates.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# # app/api/routes/templates.py
|
||||||
|
# from __future__ import annotations
|
||||||
|
# from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
# from sqlalchemy import select, or_
|
||||||
|
# from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
# from app.db.session import get_async_session
|
||||||
|
# from app.models.templates import Template
|
||||||
|
# from app.api.schemas.template import TemplateIn, TemplateOut
|
||||||
|
# from app.services.templates import count_templates
|
||||||
|
|
||||||
|
# router = APIRouter(prefix="/templates", tags=["templates"])
|
||||||
|
|
||||||
|
# # Заглушка аутентификации
|
||||||
|
# async def get_owner_id(x_user_id: int | None = None):
|
||||||
|
# return x_user_id or 0
|
||||||
|
|
||||||
|
# @router.post("/", response_model=TemplateOut)
|
||||||
|
# async def create_tpl(
|
||||||
|
# data: TemplateIn,
|
||||||
|
# owner_id: int = Depends(get_owner_id),
|
||||||
|
# s: AsyncSession = Depends(get_async_session),
|
||||||
|
# ):
|
||||||
|
# tpl = Template(owner_id=owner_id, **data.model_dump())
|
||||||
|
# s.add(tpl)
|
||||||
|
# await s.commit()
|
||||||
|
# await s.refresh(tpl)
|
||||||
|
# return tpl
|
||||||
|
|
||||||
|
# @router.get("/", response_model=list[TemplateOut])
|
||||||
|
# async def list_tpls(
|
||||||
|
# owner_id: int = Depends(get_owner_id),
|
||||||
|
# limit: int = Query(20, ge=1, le=100),
|
||||||
|
# offset: int = Query(0, ge=0),
|
||||||
|
# q: str | None = Query(default=None),
|
||||||
|
# s: AsyncSession = Depends(get_async_session),
|
||||||
|
# ):
|
||||||
|
# stmt = select(Template).where(
|
||||||
|
# Template.owner_id == owner_id,
|
||||||
|
# Template.is_archived.is_(False),
|
||||||
|
# )
|
||||||
|
# if q:
|
||||||
|
# like = f"%{q}%"
|
||||||
|
# stmt = stmt.where(or_(Template.name.ilike(like), Template.title.ilike(like)))
|
||||||
|
# stmt = stmt.order_by(Template.updated_at.desc()).limit(limit).offset(offset)
|
||||||
|
# res = await s.execute(stmt)
|
||||||
|
# return list(res.scalars())
|
||||||
|
|
||||||
|
# @router.get("/count")
|
||||||
|
# async def count_tpls(
|
||||||
|
# owner_id: int = Depends(get_owner_id),
|
||||||
|
# q: str | None = None,
|
||||||
|
# ):
|
||||||
|
# total = await count_templates(owner_id, q)
|
||||||
|
# return {"total": total}
|
||||||
|
|
||||||
|
# @router.delete("/{tpl_id}")
|
||||||
|
# async def delete_tpl(
|
||||||
|
# tpl_id: int,
|
||||||
|
# owner_id: int = Depends(get_owner_id),
|
||||||
|
# s: AsyncSession = Depends(get_async_session),
|
||||||
|
# ):
|
||||||
|
# res = await s.execute(select(Template).where(
|
||||||
|
# Template.id == tpl_id,
|
||||||
|
# Template.owner_id == owner_id
|
||||||
|
# ))
|
||||||
|
# tpl = res.scalars().first()
|
||||||
|
# if not tpl:
|
||||||
|
# raise HTTPException(404, "not found")
|
||||||
|
# await s.delete(tpl)
|
||||||
|
# await s.commit()
|
||||||
|
# return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# app/api/routes/templates.py
|
||||||
|
from __future__ import annotations
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.session import get_async_session
|
||||||
|
from app.models.templates import Template
|
||||||
|
from app.api.schemas.template import TemplateIn, TemplateOut
|
||||||
|
from app.services.templates import count_templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/templates", tags=["templates"])
|
||||||
|
|
||||||
|
async def get_owner_id(x_user_id: int | None = None):
|
||||||
|
return x_user_id or 0
|
||||||
|
|
||||||
|
@router.post("/", response_model=TemplateOut)
|
||||||
|
async def create_tpl(
|
||||||
|
data: TemplateIn,
|
||||||
|
owner_id: int = Depends(get_owner_id),
|
||||||
|
s: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
tpl = Template(owner_id=owner_id, **data.model_dump())
|
||||||
|
s.add(tpl)
|
||||||
|
await s.commit()
|
||||||
|
await s.refresh(tpl)
|
||||||
|
return tpl
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[TemplateOut])
|
||||||
|
async def list_tpls(
|
||||||
|
owner_id: int = Depends(get_owner_id),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
q: str | None = Query(default=None),
|
||||||
|
s: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
stmt = select(Template).where(
|
||||||
|
Template.owner_id == owner_id,
|
||||||
|
Template.is_archived.is_(False),
|
||||||
|
)
|
||||||
|
if q:
|
||||||
|
like = f"%{q}%"
|
||||||
|
stmt = stmt.where(or_(Template.name.ilike(like), Template.title.ilike(like)))
|
||||||
|
stmt = stmt.order_by(Template.updated_at.desc()).limit(limit).offset(offset)
|
||||||
|
res = await s.execute(stmt)
|
||||||
|
return list(res.scalars())
|
||||||
|
|
||||||
|
@router.get("/count")
|
||||||
|
async def count_tpls(owner_id: int = Depends(get_owner_id), q: str | None = None):
|
||||||
|
total = await count_templates(owner_id, q)
|
||||||
|
return {"total": total}
|
||||||
|
|
||||||
|
@router.delete("/{tpl_id}")
|
||||||
|
async def delete_tpl(
|
||||||
|
tpl_id: int,
|
||||||
|
owner_id: int = Depends(get_owner_id),
|
||||||
|
s: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
res = await s.execute(select(Template).where(
|
||||||
|
Template.id == tpl_id,
|
||||||
|
Template.owner_id == owner_id
|
||||||
|
))
|
||||||
|
tpl = res.scalars().first()
|
||||||
|
if not tpl:
|
||||||
|
raise HTTPException(404, "not found")
|
||||||
|
await s.delete(tpl)
|
||||||
|
await s.commit()
|
||||||
|
return {"ok": True}
|
||||||
0
app/api/schemas/__init__.py
Normal file
0
app/api/schemas/__init__.py
Normal file
0
app/api/schemas/base.py
Normal file
0
app/api/schemas/base.py
Normal file
0
app/api/schemas/keyboard.py
Normal file
0
app/api/schemas/keyboard.py
Normal file
0
app/api/schemas/post.py
Normal file
0
app/api/schemas/post.py
Normal file
17
app/api/schemas/template.py
Normal file
17
app/api/schemas/template.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional, Literal
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
class TemplateIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
title: Optional[str] = None
|
||||||
|
type: Literal["text","photo","video","animation"] = "text"
|
||||||
|
content: str
|
||||||
|
keyboard_tpl: Optional[list[dict]] = None
|
||||||
|
parse_mode: Optional[str] = "HTML"
|
||||||
|
visibility: Literal["private","org","public"] = "private"
|
||||||
|
|
||||||
|
class TemplateOut(TemplateIn):
|
||||||
|
id: int
|
||||||
|
is_archived: bool
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
0
app/bots/__init__.py
Normal file
0
app/bots/__init__.py
Normal file
0
app/bots/editor/__init__.py
Normal file
0
app/bots/editor/__init__.py
Normal file
90
app/bots/editor/keyboards.py
Normal file
90
app/bots/editor/keyboards.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import Iterable, List, Tuple, Optional
|
||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
|
||||||
|
|
||||||
|
class KbBuilder:
|
||||||
|
@staticmethod
|
||||||
|
def channels(channels: Iterable) -> InlineKeyboardMarkup:
|
||||||
|
rows = [[InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")]
|
||||||
|
for ch in channels]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def post_types() -> InlineKeyboardMarkup:
|
||||||
|
rows = [
|
||||||
|
[InlineKeyboardButton("Текст", callback_data="type:text"),
|
||||||
|
InlineKeyboardButton("Фото", callback_data="type:photo")],
|
||||||
|
[InlineKeyboardButton("Видео", callback_data="type:video"),
|
||||||
|
InlineKeyboardButton("GIF", callback_data="type:animation")],
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_modes() -> InlineKeyboardMarkup:
|
||||||
|
rows = [[InlineKeyboardButton("HTML", callback_data="fmt:HTML"),
|
||||||
|
InlineKeyboardButton("MarkdownV2", callback_data="fmt:MarkdownV2")]]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_confirm() -> InlineKeyboardMarkup:
|
||||||
|
rows = [
|
||||||
|
[InlineKeyboardButton("Отправить сейчас", callback_data="send:now")],
|
||||||
|
[InlineKeyboardButton("Запланировать", callback_data="send:schedule")],
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def templates_list(items: List, page: int, total: int, page_size: int) -> InlineKeyboardMarkup:
|
||||||
|
rows: List[List[InlineKeyboardButton]] = []
|
||||||
|
for t in items:
|
||||||
|
rows.append([
|
||||||
|
InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"),
|
||||||
|
InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}")
|
||||||
|
])
|
||||||
|
|
||||||
|
nav: List[InlineKeyboardButton] = []
|
||||||
|
if page > 0:
|
||||||
|
nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}"))
|
||||||
|
if (page + 1) * page_size < total:
|
||||||
|
nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}"))
|
||||||
|
if nav:
|
||||||
|
rows.append(nav)
|
||||||
|
|
||||||
|
rows.append([InlineKeyboardButton("Отмена", callback_data="tpl:cancel")])
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def preview_actions() -> InlineKeyboardMarkup:
|
||||||
|
rows = [
|
||||||
|
[InlineKeyboardButton("✅ Использовать", callback_data="pv:use")],
|
||||||
|
[InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")],
|
||||||
|
[InlineKeyboardButton("Отмена", callback_data="tpl:cancel")],
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tpl_types() -> InlineKeyboardMarkup:
|
||||||
|
rows = [
|
||||||
|
[InlineKeyboardButton("Текст", callback_data="tpltype:text"),
|
||||||
|
InlineKeyboardButton("Фото", callback_data="tpltype:photo")],
|
||||||
|
[InlineKeyboardButton("Видео", callback_data="tpltype:video"),
|
||||||
|
InlineKeyboardButton("GIF", callback_data="tpltype:animation")],
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tpl_formats() -> InlineKeyboardMarkup:
|
||||||
|
rows = [
|
||||||
|
[InlineKeyboardButton("HTML (по умолчанию)", callback_data="tplfmt:HTML")],
|
||||||
|
[InlineKeyboardButton("MarkdownV2", callback_data="tplfmt:MarkdownV2")],
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tpl_confirm_delete(tpl_id: int) -> InlineKeyboardMarkup:
|
||||||
|
rows = [
|
||||||
|
[InlineKeyboardButton("Да, удалить", callback_data=f"tpldelok:{tpl_id}")],
|
||||||
|
[InlineKeyboardButton("Отмена", callback_data="tpl:cancel")],
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
51
app/bots/editor/messages.py
Normal file
51
app/bots/editor/messages.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import shlex
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
|
class MessageParsers:
|
||||||
|
@staticmethod
|
||||||
|
def parse_template_invocation(s: str) -> tuple[str, Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Пример: "#promo title='Hi' url=https://x.y"
|
||||||
|
-> ("promo", {"title":"Hi", "url":"https://x.y"})
|
||||||
|
"""
|
||||||
|
s = (s or "").strip()
|
||||||
|
if not s.startswith("#"):
|
||||||
|
raise ValueError("not a template invocation")
|
||||||
|
parts = shlex.split(s)
|
||||||
|
name = parts[0][1:]
|
||||||
|
args: Dict[str, str] = {}
|
||||||
|
for tok in parts[1:]:
|
||||||
|
if "=" in tok:
|
||||||
|
k, v = tok.split("=", 1)
|
||||||
|
args[k] = v
|
||||||
|
return name, args
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_key_value_lines(text: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Поддерживает:
|
||||||
|
- построчно:
|
||||||
|
key=value
|
||||||
|
key2="quoted value"
|
||||||
|
- одной строкой:
|
||||||
|
key=value key2="quoted value"
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
if "\n" in text:
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
if "=" in line:
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
out[k.strip()] = v.strip().strip('"')
|
||||||
|
return out
|
||||||
|
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for tok in shlex.split(text):
|
||||||
|
if "=" in tok:
|
||||||
|
k, v = tok.split("=", 1)
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
84
app/bots/editor/oop_app.py
Normal file
84
app/bots/editor/oop_app.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
Application, CommandHandler, MessageHandler, ConversationHandler,
|
||||||
|
CallbackQueryHandler, CallbackContext, filters,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from .states import States
|
||||||
|
from .session import SessionStore
|
||||||
|
from .wizard import EditorWizard
|
||||||
|
|
||||||
|
|
||||||
|
def build_app() -> Application:
|
||||||
|
sessions = SessionStore()
|
||||||
|
wizard = EditorWizard(sessions)
|
||||||
|
|
||||||
|
app = Application.builder().token(settings.editor_bot_token).build()
|
||||||
|
|
||||||
|
# Мастер поста
|
||||||
|
post_conv = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("newpost", wizard.newpost)],
|
||||||
|
states={
|
||||||
|
States.CHOOSE_CHANNEL: [CallbackQueryHandler(wizard.choose_channel, pattern=r"^channel:")],
|
||||||
|
States.CHOOSE_TYPE: [CallbackQueryHandler(wizard.choose_type, pattern=r"^type:")],
|
||||||
|
States.CHOOSE_FORMAT: [CallbackQueryHandler(wizard.choose_format, pattern=r"^fmt:")],
|
||||||
|
|
||||||
|
States.ENTER_TEXT: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.enter_text),
|
||||||
|
CallbackQueryHandler(wizard.choose_template_open, pattern=r"^tpl:choose$"),
|
||||||
|
],
|
||||||
|
States.SELECT_TEMPLATE: [
|
||||||
|
CallbackQueryHandler(wizard.choose_template_apply, pattern=r"^tpluse:"),
|
||||||
|
CallbackQueryHandler(wizard.choose_template_preview, pattern=r"^tplprev:"),
|
||||||
|
CallbackQueryHandler(wizard.choose_template_navigate, pattern=r"^tplpage:"),
|
||||||
|
CallbackQueryHandler(wizard.choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||||
|
],
|
||||||
|
States.PREVIEW_VARS: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.preview_collect_vars)],
|
||||||
|
States.PREVIEW_CONFIRM: [
|
||||||
|
CallbackQueryHandler(wizard.preview_confirm, pattern=r"^pv:(use|edit)$"),
|
||||||
|
CallbackQueryHandler(wizard.choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||||
|
],
|
||||||
|
|
||||||
|
States.ENTER_MEDIA: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.enter_media)],
|
||||||
|
States.EDIT_KEYBOARD: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.edit_keyboard)],
|
||||||
|
|
||||||
|
States.CONFIRM_SEND: [CallbackQueryHandler(wizard.confirm_send, pattern=r"^send:")],
|
||||||
|
States.ENTER_SCHEDULE: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.enter_schedule)],
|
||||||
|
},
|
||||||
|
fallbacks=[CommandHandler("start", wizard.start)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Мастер шаблонов
|
||||||
|
tpl_conv = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("tpl_new", wizard.tpl_new_start)],
|
||||||
|
states={
|
||||||
|
States.TPL_NEW_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.tpl_new_name)],
|
||||||
|
States.TPL_NEW_TYPE: [CallbackQueryHandler(wizard.tpl_new_type, pattern=r"^tpltype:")],
|
||||||
|
States.TPL_NEW_FORMAT: [CallbackQueryHandler(wizard.tpl_new_format, pattern=r"^tplfmt:")],
|
||||||
|
States.TPL_NEW_CONTENT: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.tpl_new_content)],
|
||||||
|
States.TPL_NEW_KB: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.tpl_new_kb)],
|
||||||
|
States.TPL_CONFIRM_DELETE: [
|
||||||
|
CallbackQueryHandler(wizard.tpl_delete_ok, pattern=r"^tpldelok:"),
|
||||||
|
CallbackQueryHandler(wizard.choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fallbacks=[CommandHandler("start", wizard.start)],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_handler(CommandHandler("start", wizard.start))
|
||||||
|
app.add_handler(post_conv)
|
||||||
|
app.add_handler(tpl_conv)
|
||||||
|
app.add_handler(CommandHandler("tpl_list", wizard.tpl_list))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = build_app()
|
||||||
|
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
47
app/bots/editor/session.py
Normal file
47
app/bots/editor/session.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TTL = 60 * 60 # 1 час
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserSession:
|
||||||
|
channel_id: Optional[int] = None
|
||||||
|
type: Optional[str] = None # text/photo/video/animation
|
||||||
|
parse_mode: Optional[str] = None # HTML/MarkdownV2
|
||||||
|
text: Optional[str] = None
|
||||||
|
media_file_id: Optional[str] = None
|
||||||
|
keyboard: Optional[dict] = None # {"rows": [[{"text","url"}], ...]}
|
||||||
|
last_activity: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
def touch(self) -> None:
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStore:
|
||||||
|
"""Простое и быстрое in-memory хранилище с авто-очисткой."""
|
||||||
|
|
||||||
|
def __init__(self, ttl: int = DEFAULT_TTL) -> None:
|
||||||
|
self._data: Dict[int, UserSession] = {}
|
||||||
|
self._ttl = ttl
|
||||||
|
|
||||||
|
def get(self, uid: int) -> UserSession:
|
||||||
|
s = self._data.get(uid)
|
||||||
|
if not s:
|
||||||
|
s = UserSession()
|
||||||
|
self._data[uid] = s
|
||||||
|
s.touch()
|
||||||
|
self._cleanup()
|
||||||
|
return s
|
||||||
|
|
||||||
|
def drop(self, uid: int) -> None:
|
||||||
|
self._data.pop(uid, None)
|
||||||
|
|
||||||
|
def _cleanup(self) -> None:
|
||||||
|
now = time.time()
|
||||||
|
for uid in list(self._data.keys()):
|
||||||
|
if now - self._data[uid].last_activity > self._ttl:
|
||||||
|
del self._data[uid]
|
||||||
24
app/bots/editor/states.py
Normal file
24
app/bots/editor/states.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
|
class States(IntEnum):
|
||||||
|
CHOOSE_CHANNEL = 0
|
||||||
|
CHOOSE_TYPE = 1
|
||||||
|
CHOOSE_FORMAT = 2
|
||||||
|
ENTER_TEXT = 3
|
||||||
|
ENTER_MEDIA = 4
|
||||||
|
EDIT_KEYBOARD = 5
|
||||||
|
CONFIRM_SEND = 6
|
||||||
|
ENTER_SCHEDULE = 7
|
||||||
|
|
||||||
|
SELECT_TEMPLATE = 8
|
||||||
|
PREVIEW_VARS = 9
|
||||||
|
PREVIEW_CONFIRM = 10
|
||||||
|
|
||||||
|
TPL_NEW_NAME = 11
|
||||||
|
TPL_NEW_TYPE = 12
|
||||||
|
TPL_NEW_FORMAT = 13
|
||||||
|
TPL_NEW_CONTENT = 14
|
||||||
|
TPL_NEW_KB = 15
|
||||||
|
TPL_CONFIRM_DELETE = 16
|
||||||
485
app/bots/editor/wizard.py
Normal file
485
app/bots/editor/wizard.py
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import CallbackContext
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.tasks.senders import send_post_task
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
from app.models.channel import Channel
|
||||||
|
from app.services.templates import (
|
||||||
|
render_template_by_name, list_templates, count_templates,
|
||||||
|
create_template, delete_template, required_variables_of_template,
|
||||||
|
)
|
||||||
|
from jinja2 import TemplateError
|
||||||
|
|
||||||
|
from .states import States
|
||||||
|
from .session import SessionStore
|
||||||
|
from .messages import MessageParsers
|
||||||
|
from .keyboards import KbBuilder
|
||||||
|
|
||||||
|
|
||||||
|
# Заглушка для build_payload, если сервиса нет
|
||||||
|
try:
|
||||||
|
from app.services.telegram import build_payload # type: ignore
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
def build_payload(ptype: str, text: str | None, media_file_id: str | None,
|
||||||
|
parse_mode: str | None, keyboard: dict | None) -> dict:
|
||||||
|
return {
|
||||||
|
"type": ptype,
|
||||||
|
"text": text,
|
||||||
|
"media_file_id": media_file_id,
|
||||||
|
"parse_mode": parse_mode,
|
||||||
|
"keyboard": keyboard,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
PAGE_SIZE = 8
|
||||||
|
|
||||||
|
|
||||||
|
class EditorWizard:
|
||||||
|
"""Инкапсулирует весь сценарий мастера и управление шаблонами."""
|
||||||
|
|
||||||
|
def __init__(self, sessions: SessionStore) -> None:
|
||||||
|
self.sessions = sessions
|
||||||
|
|
||||||
|
# ---------- Команды верхнего уровня ----------
|
||||||
|
|
||||||
|
async def start(self, update: Update, context: CallbackContext):
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Привет! Я редактор.\n"
|
||||||
|
"Команды: /newpost — мастер поста, /tpl_new — создать шаблон, /tpl_list — список шаблонов."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def newpost(self, update: Update, context: CallbackContext):
|
||||||
|
uid = update.effective_user.id
|
||||||
|
s = self.sessions.get(uid) # инициализация
|
||||||
|
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
res = await db.execute(select(Channel).where(Channel.owner_id == uid).limit(50))
|
||||||
|
channels = list(res.scalars())
|
||||||
|
|
||||||
|
if not channels:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Пока нет каналов. Добавь через админку или команду /add_channel (в разработке)."
|
||||||
|
)
|
||||||
|
return -1
|
||||||
|
|
||||||
|
await update.message.reply_text("Выбери канал для публикации:", reply_markup=KbBuilder.channels(channels))
|
||||||
|
return States.CHOOSE_CHANNEL
|
||||||
|
|
||||||
|
# ---------- Выбор канала/типа/формата ----------
|
||||||
|
|
||||||
|
async def choose_channel(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
s = self.sessions.get(uid)
|
||||||
|
|
||||||
|
ch_id = int(q.data.split(":", 1)[1])
|
||||||
|
s.channel_id = ch_id
|
||||||
|
|
||||||
|
await q.edit_message_text("Тип поста:", reply_markup=KbBuilder.post_types())
|
||||||
|
return States.CHOOSE_TYPE
|
||||||
|
|
||||||
|
async def choose_type(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
s = self.sessions.get(uid)
|
||||||
|
|
||||||
|
s.type = q.data.split(":", 1)[1]
|
||||||
|
await q.edit_message_text("Выбери формат разметки (по умолчанию HTML):", reply_markup=KbBuilder.parse_modes())
|
||||||
|
return States.CHOOSE_FORMAT
|
||||||
|
|
||||||
|
async def choose_format(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
s = self.sessions.get(uid)
|
||||||
|
|
||||||
|
s.parse_mode = q.data.split(":", 1)[1]
|
||||||
|
|
||||||
|
if s.type == "text":
|
||||||
|
await q.edit_message_text("Отправь текст сообщения или выбери шаблон:", reply_markup=KbBuilder.templates_list([], 0, 0, PAGE_SIZE))
|
||||||
|
# Доп. сообщение с кнопкой «Выбрать шаблон»
|
||||||
|
await q.message.reply_text("Нажми «Выбрать шаблон»", reply_markup=None)
|
||||||
|
return States.ENTER_TEXT
|
||||||
|
else:
|
||||||
|
await q.edit_message_text(
|
||||||
|
"Пришли медиадескриптор (file_id) и, при желании, подпись.\nФормат: FILE_ID|Подпись"
|
||||||
|
)
|
||||||
|
return States.ENTER_MEDIA
|
||||||
|
|
||||||
|
# ---------- Ввод текста/медиа ----------
|
||||||
|
|
||||||
|
async def enter_text(self, update: Update, context: CallbackContext):
|
||||||
|
uid = update.effective_user.id
|
||||||
|
s = self.sessions.get(uid)
|
||||||
|
text = update.message.text or ""
|
||||||
|
|
||||||
|
if text.strip().startswith("#"):
|
||||||
|
# Вызов шаблона: #name key=val ...
|
||||||
|
try:
|
||||||
|
name, ctx_vars = MessageParsers.parse_template_invocation(text)
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text("Не распознал шаблон. Пример: #promo title='Привет' url=https://x.y")
|
||||||
|
return States.ENTER_TEXT
|
||||||
|
|
||||||
|
tpl_meta = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||||
|
required = set(tpl_meta.get("_required", []))
|
||||||
|
missing = sorted(list(required - set(ctx_vars.keys())))
|
||||||
|
|
||||||
|
context.user_data["preview"] = {"name": name, "provided": ctx_vars, "missing": missing}
|
||||||
|
if missing:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Не хватает переменных: " + ", ".join(missing) +
|
||||||
|
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||||
|
)
|
||||||
|
return States.PREVIEW_VARS
|
||||||
|
|
||||||
|
# все есть — применяем
|
||||||
|
return await self._apply_template_and_confirm(update, uid, name, ctx_vars)
|
||||||
|
|
||||||
|
s.text = text
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Добавить кнопки? Пришлите строки вида: Заголовок|URL или '-' чтобы пропустить."
|
||||||
|
)
|
||||||
|
return States.EDIT_KEYBOARD
|
||||||
|
|
||||||
|
async def enter_media(self, update: Update, context: CallbackContext):
|
||||||
|
uid = update.effective_user.id
|
||||||
|
s = self.sessions.get(uid)
|
||||||
|
|
||||||
|
parts = (update.message.text or "").split("|", 1)
|
||||||
|
s.media_file_id = parts[0].strip()
|
||||||
|
s.text = parts[1].strip() if len(parts) > 1 else None
|
||||||
|
|
||||||
|
await update.message.reply_text("Добавить кнопки? Пришлите строки: Текст|URL. Или '-' чтобы пропустить.")
|
||||||
|
return States.EDIT_KEYBOARD
|
||||||
|
|
||||||
|
# ---------- Работа со списком шаблонов (кнопка) ----------
|
||||||
|
|
||||||
|
async def choose_template_open(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
context.user_data["tpl_page"] = 0
|
||||||
|
return await self._render_tpl_list(q, uid, page=0)
|
||||||
|
|
||||||
|
async def choose_template_navigate(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
page = int(q.data.split(":", 1)[1])
|
||||||
|
context.user_data["tpl_page"] = page
|
||||||
|
return await self._render_tpl_list(q, uid, page)
|
||||||
|
|
||||||
|
async def _render_tpl_list(self, q_or_msg, uid: int, page: int):
|
||||||
|
total = await count_templates(uid)
|
||||||
|
items = await list_templates(uid, limit=PAGE_SIZE, offset=page * PAGE_SIZE)
|
||||||
|
if not items:
|
||||||
|
text = "Шаблонов пока нет. Создай через /tpl_new."
|
||||||
|
if hasattr(q_or_msg, "edit_message_text"):
|
||||||
|
await q_or_msg.edit_message_text(text)
|
||||||
|
else:
|
||||||
|
await q_or_msg.reply_text(text)
|
||||||
|
return States.ENTER_TEXT
|
||||||
|
|
||||||
|
markup = KbBuilder.templates_list(items, page, total, PAGE_SIZE)
|
||||||
|
text = f"Шаблоны (стр. {page+1}/{(total-1)//PAGE_SIZE + 1}):"
|
||||||
|
if hasattr(q_or_msg, "edit_message_text"):
|
||||||
|
await q_or_msg.edit_message_text(text, reply_markup=markup)
|
||||||
|
else:
|
||||||
|
await q_or_msg.reply_text(text, reply_markup=markup)
|
||||||
|
return States.SELECT_TEMPLATE
|
||||||
|
|
||||||
|
async def choose_template_apply(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
name = q.data.split(":", 1)[1]
|
||||||
|
|
||||||
|
tpl_meta = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||||
|
required = set(tpl_meta.get("_required", []))
|
||||||
|
if required:
|
||||||
|
context.user_data["preview"] = {"name": name, "provided": {}, "missing": sorted(list(required))}
|
||||||
|
await q.edit_message_text(
|
||||||
|
"Шаблон требует переменные: " + ", ".join(sorted(list(required))) +
|
||||||
|
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||||
|
)
|
||||||
|
return States.PREVIEW_VARS
|
||||||
|
|
||||||
|
return await self._apply_template_and_confirm(q, uid, name, {})
|
||||||
|
|
||||||
|
async def choose_template_preview(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
name = q.data.split(":", 1)[1]
|
||||||
|
meta = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||||
|
required = set(meta.get("_required", []))
|
||||||
|
context.user_data["preview"] = {"name": name, "provided": {}, "missing": sorted(list(required))}
|
||||||
|
if required:
|
||||||
|
await q.edit_message_text(
|
||||||
|
"Для предпросмотра нужны переменные: " + ", ".join(sorted(list(required))) +
|
||||||
|
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||||
|
)
|
||||||
|
return States.PREVIEW_VARS
|
||||||
|
|
||||||
|
return await self._render_preview_and_confirm(q, uid, name, {})
|
||||||
|
|
||||||
|
async def choose_template_cancel(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
await q.edit_message_text("Отправь текст сообщения или введи #имя для шаблона.")
|
||||||
|
return States.ENTER_TEXT
|
||||||
|
|
||||||
|
# ---------- Предпросмотр: сбор переменных / подтверждение ----------
|
||||||
|
|
||||||
|
async def preview_collect_vars(self, update: Update, context: CallbackContext):
|
||||||
|
uid = update.effective_user.id
|
||||||
|
data = context.user_data.get("preview", {})
|
||||||
|
provided: Dict[str, str] = dict(data.get("provided", {}))
|
||||||
|
missing = set(data.get("missing", []))
|
||||||
|
|
||||||
|
provided.update(MessageParsers.parse_key_value_lines(update.message.text))
|
||||||
|
still_missing = sorted(list(missing - set(provided.keys())))
|
||||||
|
if still_missing:
|
||||||
|
context.user_data["preview"] = {"name": data.get("name"), "provided": provided, "missing": still_missing}
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Ещё не хватает: " + ", ".join(still_missing) +
|
||||||
|
"\nПришли оставшиеся значения в формате key=value."
|
||||||
|
)
|
||||||
|
return States.PREVIEW_VARS
|
||||||
|
|
||||||
|
context.user_data["preview"] = {"name": data.get("name"), "provided": provided, "missing": []}
|
||||||
|
return await self._render_preview_and_confirm(update.message, uid, data.get("name"), provided)
|
||||||
|
|
||||||
|
async def preview_confirm(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
action = q.data.split(":", 1)[1]
|
||||||
|
data = context.user_data.get("preview", {})
|
||||||
|
name = data.get("name")
|
||||||
|
provided = data.get("provided", {})
|
||||||
|
|
||||||
|
if action == "use":
|
||||||
|
return await self._apply_template_and_confirm(q, uid, name, provided)
|
||||||
|
|
||||||
|
# edit variables
|
||||||
|
meta = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||||
|
required = set(meta.get("_required", []))
|
||||||
|
missing = sorted(list(required - set(provided.keys())))
|
||||||
|
if missing:
|
||||||
|
await q.edit_message_text(
|
||||||
|
"Ещё требуются: " + ", ".join(missing) +
|
||||||
|
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await q.edit_message_text("Измени значения переменных и пришли заново (key=value ...).")
|
||||||
|
context.user_data["preview"] = {"name": name, "provided": provided, "missing": missing}
|
||||||
|
return States.PREVIEW_VARS
|
||||||
|
|
||||||
|
# ---------- Редактор клавиатуры / подтверждение отправки ----------
|
||||||
|
|
||||||
|
async def edit_keyboard(self, update: Update, context: CallbackContext):
|
||||||
|
uid = update.effective_user.id
|
||||||
|
s = self.sessions.get(uid)
|
||||||
|
raw = (update.message.text or "").strip()
|
||||||
|
|
||||||
|
if raw == "-":
|
||||||
|
s.keyboard = None
|
||||||
|
else:
|
||||||
|
buttons = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if "|" in line:
|
||||||
|
t, u = line.split("|", 1)
|
||||||
|
buttons.append((t.strip(), u.strip()))
|
||||||
|
keyboard_rows = [[{"text": t, "url": u}] for t, u in buttons] if buttons else None
|
||||||
|
s.keyboard = {"rows": keyboard_rows} if keyboard_rows else None
|
||||||
|
|
||||||
|
await update.message.reply_text("Как публикуем?", reply_markup=KbBuilder.send_confirm())
|
||||||
|
return States.CONFIRM_SEND
|
||||||
|
|
||||||
|
async def confirm_send(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
action = q.data.split(":", 1)[1]
|
||||||
|
if action == "now":
|
||||||
|
await self._dispatch_now(uid, q)
|
||||||
|
self.sessions.drop(uid)
|
||||||
|
return -1
|
||||||
|
else:
|
||||||
|
await q.edit_message_text("Укажи время в формате YYYY-MM-DD HH:MM (Asia/Seoul)")
|
||||||
|
return States.ENTER_SCHEDULE
|
||||||
|
|
||||||
|
async def enter_schedule(self, update: Update, context: CallbackContext):
|
||||||
|
uid = update.effective_user.id
|
||||||
|
when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M")
|
||||||
|
await self._dispatch_with_eta(uid, when)
|
||||||
|
await update.message.reply_text("Задача запланирована.")
|
||||||
|
self.sessions.drop(uid)
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# ---------- Создание/удаление шаблонов ----------
|
||||||
|
|
||||||
|
async def tpl_new_start(self, update: Update, context: CallbackContext):
|
||||||
|
uid = update.effective_user.id
|
||||||
|
context.user_data["tpl"] = {"owner_id": uid}
|
||||||
|
await update.message.reply_text("Создание шаблона. Введи короткое имя (латиница/цифры), которым будешь вызывать: #имя")
|
||||||
|
return States.TPL_NEW_NAME
|
||||||
|
|
||||||
|
async def tpl_new_name(self, update: Update, context: CallbackContext):
|
||||||
|
name = (update.message.text or "").strip()
|
||||||
|
context.user_data["tpl"]["name"] = name
|
||||||
|
await update.message.reply_text("Выбери тип шаблона:", reply_markup=KbBuilder.tpl_types())
|
||||||
|
return States.TPL_NEW_TYPE
|
||||||
|
|
||||||
|
async def tpl_new_type(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
t = q.data.split(":", 1)[1]
|
||||||
|
context.user_data["tpl"]["type"] = t
|
||||||
|
await q.edit_message_text("Выбери формат разметки для этого шаблона:", reply_markup=KbBuilder.tpl_formats())
|
||||||
|
return States.TPL_NEW_FORMAT
|
||||||
|
|
||||||
|
async def tpl_new_format(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
fmt = q.data.split(":", 1)[1]
|
||||||
|
context.user_data["tpl"]["parse_mode"] = fmt
|
||||||
|
await q.edit_message_text(
|
||||||
|
"Введи Jinja2‑шаблон текста.\n\n"
|
||||||
|
"Если выбрал MarkdownV2, экранируй пользовательские значения фильтром {{ var|mdv2 }}.\n"
|
||||||
|
"Для HTML используй {{ var|html }} при необходимости."
|
||||||
|
)
|
||||||
|
return States.TPL_NEW_CONTENT
|
||||||
|
|
||||||
|
async def tpl_new_content(self, update: Update, context: CallbackContext):
|
||||||
|
context.user_data["tpl"]["content"] = update.message.text
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Добавь кнопки (по желанию). Пришли строки вида: Текст|URL, можно несколько строк. Либо '-' чтобы пропустить."
|
||||||
|
)
|
||||||
|
return States.TPL_NEW_KB
|
||||||
|
|
||||||
|
async def tpl_new_kb(self, update: Update, context: CallbackContext):
|
||||||
|
uid = update.effective_user.id
|
||||||
|
raw = (update.message.text or "").strip()
|
||||||
|
kb_tpl = None if raw == "-" else self._parse_kb_lines(raw)
|
||||||
|
data = context.user_data.get("tpl", {})
|
||||||
|
parse_mode = data.get("parse_mode") or "HTML"
|
||||||
|
|
||||||
|
tpl_id = await create_template(
|
||||||
|
owner_id=uid,
|
||||||
|
name=data.get("name"),
|
||||||
|
title=None,
|
||||||
|
type_=data.get("type"),
|
||||||
|
content=data.get("content"),
|
||||||
|
keyboard_tpl=kb_tpl,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
)
|
||||||
|
req = required_variables_of_template(data.get("content") or "", kb_tpl or [])
|
||||||
|
extra = f"\nТребуемые переменные: {', '.join(sorted(req))}" if req else ""
|
||||||
|
await update.message.reply_text(f"Шаблон сохранён (id={tpl_id}). Используй: #{data.get('name')} key=value ...{extra}")
|
||||||
|
context.user_data.pop("tpl", None)
|
||||||
|
return -1
|
||||||
|
|
||||||
|
async def tpl_list(self, update: Update, context: CallbackContext):
|
||||||
|
context.user_data["tpl_page"] = 0
|
||||||
|
return await self._render_tpl_list(update.message, update.effective_user.id, page=0)
|
||||||
|
|
||||||
|
async def tpl_confirm_delete(self, update: Update, context: CallbackContext):
|
||||||
|
from .keyboards import KbBuilder # локальный импорт уже есть, просто используем метод
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
tpl_id = int(q.data.split(":", 1)[1])
|
||||||
|
await q.edit_message_text("Удалить шаблон?", reply_markup=KbBuilder.tpl_confirm_delete(tpl_id))
|
||||||
|
return States.TPL_CONFIRM_DELETE
|
||||||
|
|
||||||
|
async def tpl_delete_ok(self, update: Update, context: CallbackContext):
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
tpl_id = int(q.data.split(":", 1)[1])
|
||||||
|
ok = await delete_template(owner_id=uid, tpl_id=tpl_id)
|
||||||
|
await q.edit_message_text("Удалено" if ok else "Не найдено")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# ---------- Внутренние утилиты ----------
|
||||||
|
|
||||||
|
async def _render_preview_and_confirm(self, q_or_msg, uid: int, name: str, ctx_vars: dict):
|
||||||
|
rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars)
|
||||||
|
text = rendered["text"]
|
||||||
|
parse_mode = rendered.get("parse_mode") or self.sessions.get(uid).parse_mode or "HTML"
|
||||||
|
|
||||||
|
if hasattr(q_or_msg, "edit_message_text"):
|
||||||
|
await q_or_msg.edit_message_text(f"Предпросмотр:\n\n{text[:3500]}", parse_mode=parse_mode)
|
||||||
|
else:
|
||||||
|
await q_or_msg.reply_text(f"Предпросмотр:\n\n{text[:3500]}", parse_mode=parse_mode)
|
||||||
|
|
||||||
|
# кнопки отдельным сообщением при необходимости
|
||||||
|
if hasattr(q_or_msg, "message") and q_or_msg.message:
|
||||||
|
await q_or_msg.message.reply_text("Что дальше?", reply_markup=KbBuilder.preview_actions())
|
||||||
|
else:
|
||||||
|
# если это message, просто второй месседж
|
||||||
|
if not hasattr(q_or_msg, "edit_message_text"):
|
||||||
|
await q_or_msg.reply_text("Что дальше?", reply_markup=KbBuilder.preview_actions())
|
||||||
|
return States.PREVIEW_CONFIRM
|
||||||
|
|
||||||
|
async def _apply_template_and_confirm(self, q_or_msg, uid: int, name: str, ctx_vars: dict):
|
||||||
|
rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars)
|
||||||
|
s = self.sessions.get(uid)
|
||||||
|
s.type = rendered["type"]
|
||||||
|
s.text = rendered["text"]
|
||||||
|
s.keyboard = {"rows": rendered["keyboard_rows"]} if rendered["keyboard_rows"] else None
|
||||||
|
s.parse_mode = rendered.get("parse_mode") or s.parse_mode or "HTML"
|
||||||
|
|
||||||
|
if hasattr(q_or_msg, "edit_message_text"):
|
||||||
|
await q_or_msg.edit_message_text("Шаблон применён. Как публикуем?", reply_markup=KbBuilder.send_confirm())
|
||||||
|
else:
|
||||||
|
await q_or_msg.reply_text("Шаблон применён. Как публикуем?", reply_markup=KbBuilder.send_confirm())
|
||||||
|
return States.CONFIRM_SEND
|
||||||
|
|
||||||
|
async def _dispatch_now(self, uid: int, qmsg):
|
||||||
|
s = self.sessions.get(uid)
|
||||||
|
if not s or not s.channel_id:
|
||||||
|
await qmsg.edit_message_text("Сессия потеряна.")
|
||||||
|
return
|
||||||
|
token = settings.editor_bot_token
|
||||||
|
payload = build_payload(
|
||||||
|
ptype=s.type,
|
||||||
|
text=s.text,
|
||||||
|
media_file_id=s.media_file_id,
|
||||||
|
parse_mode=s.parse_mode or "HTML",
|
||||||
|
keyboard=s.keyboard,
|
||||||
|
)
|
||||||
|
send_post_task.delay(token, s.channel_id, payload)
|
||||||
|
await qmsg.edit_message_text("Отправка запущена.")
|
||||||
|
|
||||||
|
async def _dispatch_with_eta(self, uid: int, when: datetime):
|
||||||
|
s = self.sessions.get(uid)
|
||||||
|
token = settings.editor_bot_token
|
||||||
|
payload = build_payload(
|
||||||
|
ptype=s.type,
|
||||||
|
text=s.text,
|
||||||
|
media_file_id=s.media_file_id,
|
||||||
|
parse_mode=s.parse_mode or "HTML",
|
||||||
|
keyboard=s.keyboard,
|
||||||
|
)
|
||||||
|
send_post_task.apply_async(args=[token, s.channel_id, payload], eta=when)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_kb_lines(raw: str) -> list[dict]:
|
||||||
|
rows: list[dict] = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if "|" in line:
|
||||||
|
t, u = line.split("|", 1)
|
||||||
|
rows.append({"text": t.strip(), "url": u.strip()})
|
||||||
|
return rows
|
||||||
589
app/bots/editor_bot.py
Normal file
589
app/bots/editor_bot.py
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import shlex
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, List, Any
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from telegram.ext import (
|
||||||
|
Application, CommandHandler, MessageHandler, ConversationHandler,
|
||||||
|
CallbackQueryHandler, CallbackContext, filters,
|
||||||
|
)
|
||||||
|
from telegram.error import TelegramError
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.tasks.senders import send_post_task
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
from app.models.channel import Channel
|
||||||
|
from app.models.post import PostType
|
||||||
|
from app.services.templates import (
|
||||||
|
render_template_by_name, list_templates, count_templates,
|
||||||
|
create_template, delete_template, required_variables_of_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
from jinja2 import TemplateError
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Константы
|
||||||
|
MAX_MESSAGE_LENGTH = 4096
|
||||||
|
PAGE_SIZE = 8
|
||||||
|
SESSION_TIMEOUT = 3600 # 1 час
|
||||||
|
ALLOWED_URL_SCHEMES = ('http', 'https', 't.me')
|
||||||
|
|
||||||
|
# Состояния диалога
|
||||||
|
(
|
||||||
|
CHOOSE_CHANNEL, CHOOSE_TYPE, CHOOSE_FORMAT, ENTER_TEXT, ENTER_MEDIA, EDIT_KEYBOARD,
|
||||||
|
CONFIRM_SEND, ENTER_SCHEDULE, SELECT_TEMPLATE,
|
||||||
|
PREVIEW_VARS, PREVIEW_CONFIRM,
|
||||||
|
TPL_NEW_NAME, TPL_NEW_TYPE, TPL_NEW_FORMAT, TPL_NEW_CONTENT, TPL_NEW_KB, TPL_CONFIRM_DELETE,
|
||||||
|
) = range(16)
|
||||||
|
|
||||||
|
# In-memory сессии с метаданными
|
||||||
|
session: Dict[int, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def validate_url(url: str) -> bool:
|
||||||
|
"""Проверка безопасности URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Строка URL для проверки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если URL безопасен, False в противном случае
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = urlparse(url)
|
||||||
|
return all([
|
||||||
|
result.scheme in ALLOWED_URL_SCHEMES,
|
||||||
|
result.netloc,
|
||||||
|
len(url) < 2048 # Максимальная длина URL
|
||||||
|
])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"URL validation failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def validate_message_length(text: str) -> bool:
|
||||||
|
"""Проверка длины сообщения согласно лимитам Telegram.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст для проверки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если длина в пределах лимита
|
||||||
|
"""
|
||||||
|
return len(text) <= MAX_MESSAGE_LENGTH
|
||||||
|
|
||||||
|
def update_session_activity(uid: int) -> None:
|
||||||
|
"""Обновление времени последней активности в сессии."""
|
||||||
|
if uid in session:
|
||||||
|
session[uid]['last_activity'] = time.time()
|
||||||
|
|
||||||
|
def cleanup_old_sessions() -> None:
|
||||||
|
"""Периодическая очистка старых сессий."""
|
||||||
|
current_time = time.time()
|
||||||
|
for uid in list(session.keys()):
|
||||||
|
if current_time - session[uid].get('last_activity', 0) > SESSION_TIMEOUT:
|
||||||
|
logger.info(f"Cleaning up session for user {uid}")
|
||||||
|
del session[uid]
|
||||||
|
|
||||||
|
def parse_template_invocation(s: str) -> tuple[str, dict]:
|
||||||
|
"""Разбор строки вызова шаблона.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
s: Строка в формате #template_name key1=value1 key2=value2
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (имя_шаблона, словарь_параметров)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Если неверный формат строки
|
||||||
|
"""
|
||||||
|
s = s.strip()
|
||||||
|
if not s.startswith("#"):
|
||||||
|
raise ValueError("Имя шаблона должно начинаться с #")
|
||||||
|
parts = shlex.split(s)
|
||||||
|
if not parts:
|
||||||
|
raise ValueError("Пустой шаблон")
|
||||||
|
name = parts[0][1:]
|
||||||
|
args: dict[str, str] = {}
|
||||||
|
for tok in parts[1:]:
|
||||||
|
if "=" in tok:
|
||||||
|
k, v = tok.split("=", 1)
|
||||||
|
args[k.strip()] = v.strip()
|
||||||
|
return name, args
|
||||||
|
|
||||||
|
def parse_key_value_lines(text: str) -> dict:
|
||||||
|
"""Парсинг строк формата key=value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Строки в формате key=value или key="quoted value"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Словарь параметров
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
if "\n" in text:
|
||||||
|
for line in text.splitlines():
|
||||||
|
if "=" in line:
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
v = v.strip().strip('"')
|
||||||
|
if k.strip(): # Проверка на пустой ключ
|
||||||
|
out[k.strip()] = v
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
for tok in shlex.split(text):
|
||||||
|
if "=" in tok:
|
||||||
|
k, v = tok.split("=", 1)
|
||||||
|
if k.strip(): # Проверка на пустой ключ
|
||||||
|
out[k.strip()] = v
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"Error parsing key-value line: {e}")
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
# -------- Команды верхнего уровня ---------
|
||||||
|
|
||||||
|
async def start(update: Update, context: CallbackContext) -> None:
|
||||||
|
"""Обработчик команды /start."""
|
||||||
|
update_session_activity(update.effective_user.id)
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Привет! Я редактор. Команды:\n"
|
||||||
|
"/newpost — мастер поста\n"
|
||||||
|
"/tpl_new — создать шаблон\n"
|
||||||
|
"/tpl_list — список шаблонов"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def newpost(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Начало создания нового поста."""
|
||||||
|
uid = update.effective_user.id
|
||||||
|
update_session_activity(uid)
|
||||||
|
|
||||||
|
session[uid] = {'last_activity': time.time()}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session_maker() as s:
|
||||||
|
res = await s.execute(select(Channel).where(Channel.owner_id == uid).limit(50))
|
||||||
|
channels = list(res.scalars())
|
||||||
|
|
||||||
|
if not channels:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Пока нет каналов. Добавь через админку или команду /add_channel (в разработке)."
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
kb = [
|
||||||
|
[InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")]
|
||||||
|
for ch in channels
|
||||||
|
]
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Выбери канал для публикации:",
|
||||||
|
reply_markup=InlineKeyboardMarkup(kb)
|
||||||
|
)
|
||||||
|
return CHOOSE_CHANNEL
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in newpost: {e}")
|
||||||
|
await update.message.reply_text("Произошла ошибка. Попробуйте позже.")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def choose_channel(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Обработка выбора канала."""
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
|
||||||
|
uid = update.effective_user.id
|
||||||
|
update_session_activity(uid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ch_id = int(q.data.split(":")[1])
|
||||||
|
session[uid]["channel_id"] = ch_id
|
||||||
|
|
||||||
|
kb = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("Текст", callback_data="type:text"),
|
||||||
|
InlineKeyboardButton("Фото", callback_data="type:photo")
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("Видео", callback_data="type:video"),
|
||||||
|
InlineKeyboardButton("GIF", callback_data="type:animation")
|
||||||
|
],
|
||||||
|
]
|
||||||
|
await q.edit_message_text(
|
||||||
|
"Тип поста:",
|
||||||
|
reply_markup=InlineKeyboardMarkup(kb)
|
||||||
|
)
|
||||||
|
return CHOOSE_TYPE
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await q.edit_message_text("Ошибка: неверный формат ID канала")
|
||||||
|
return ConversationHandler.END
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in choose_channel: {e}")
|
||||||
|
await q.edit_message_text("Произошла ошибка. Попробуйте заново.")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
# ... [Остальные функции обновляются аналогично] ...
|
||||||
|
|
||||||
|
async def enter_schedule(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Обработка ввода времени для отложенной публикации."""
|
||||||
|
uid = update.effective_user.id
|
||||||
|
update_session_activity(uid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
if when < datetime.now():
|
||||||
|
await update.message.reply_text("Дата должна быть в будущем. Укажите корректное время в формате YYYY-MM-DD HH:MM")
|
||||||
|
return ENTER_SCHEDULE
|
||||||
|
|
||||||
|
await _dispatch_with_eta(uid, when)
|
||||||
|
await update.message.reply_text("Задача запланирована.")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Неверный формат даты. Используйте формат YYYY-MM-DD HH:MM"
|
||||||
|
)
|
||||||
|
return ENTER_SCHEDULE
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error scheduling post: {e}")
|
||||||
|
await update.message.reply_text("Ошибка планирования. Попробуйте позже.")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def _dispatch_with_eta(uid: int, when: datetime) -> None:
|
||||||
|
"""Отправка отложенного поста."""
|
||||||
|
data = session.get(uid)
|
||||||
|
if not data:
|
||||||
|
raise ValueError("Сессия потеряна")
|
||||||
|
|
||||||
|
token = settings.editor_bot_token
|
||||||
|
try:
|
||||||
|
payload = build_payload(
|
||||||
|
ptype=data.get("type"),
|
||||||
|
text=data.get("text"),
|
||||||
|
media_file_id=data.get("media_file_id"),
|
||||||
|
parse_mode=data.get("parse_mode") or "HTML",
|
||||||
|
keyboard=data.get("keyboard"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверка длины сообщения
|
||||||
|
if not validate_message_length(payload.get("text", "")):
|
||||||
|
raise ValueError("Превышен максимальный размер сообщения")
|
||||||
|
|
||||||
|
# Проверка URL в клавиатуре
|
||||||
|
if keyboard := payload.get("keyboard"):
|
||||||
|
for row in keyboard.get("rows", []):
|
||||||
|
for btn in row:
|
||||||
|
if "url" in btn and not validate_url(btn["url"]):
|
||||||
|
raise ValueError(f"Небезопасный URL: {btn['url']}")
|
||||||
|
|
||||||
|
send_post_task.apply_async(
|
||||||
|
args=[token, data["channel_id"], payload],
|
||||||
|
eta=when
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in _dispatch_with_eta: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Инициализация и запуск бота."""
|
||||||
|
try:
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
level=logging.INFO
|
||||||
|
)
|
||||||
|
|
||||||
|
# Инициализация планировщика
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
scheduler.add_job(cleanup_old_sessions, 'interval', minutes=30)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
app = Application.builder().token(settings.editor_bot_token).build()
|
||||||
|
|
||||||
|
# Регистрация обработчиков
|
||||||
|
post_conv = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("newpost", newpost)],
|
||||||
|
states={
|
||||||
|
CHOOSE_CHANNEL: [CallbackQueryHandler(choose_channel, pattern=r"^channel:")],
|
||||||
|
CHOOSE_TYPE: [CallbackQueryHandler(choose_type, pattern=r"^type:")],
|
||||||
|
CHOOSE_FORMAT: [CallbackQueryHandler(choose_format, pattern=r"^fmt:")],
|
||||||
|
ENTER_TEXT: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text),
|
||||||
|
CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$"),
|
||||||
|
],
|
||||||
|
SELECT_TEMPLATE: [
|
||||||
|
CallbackQueryHandler(choose_template_apply, pattern=r"^tpluse:"),
|
||||||
|
CallbackQueryHandler(choose_template_preview, pattern=r"^tplprev:"),
|
||||||
|
CallbackQueryHandler(choose_template_navigate, pattern=r"^tplpage:"),
|
||||||
|
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||||
|
],
|
||||||
|
PREVIEW_VARS: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars)
|
||||||
|
],
|
||||||
|
PREVIEW_CONFIRM: [
|
||||||
|
CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"),
|
||||||
|
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||||
|
],
|
||||||
|
ENTER_MEDIA: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_media)
|
||||||
|
],
|
||||||
|
EDIT_KEYBOARD: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard)
|
||||||
|
],
|
||||||
|
CONFIRM_SEND: [
|
||||||
|
CallbackQueryHandler(confirm_send, pattern=r"^send:")
|
||||||
|
],
|
||||||
|
ENTER_SCHEDULE: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fallbacks=[CommandHandler("start", start)],
|
||||||
|
)
|
||||||
|
|
||||||
|
tpl_conv = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("tpl_new", tpl_new_start)],
|
||||||
|
states={
|
||||||
|
TPL_NEW_NAME: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_name)
|
||||||
|
],
|
||||||
|
TPL_NEW_TYPE: [
|
||||||
|
CallbackQueryHandler(tpl_new_type, pattern=r"^tpltype:")
|
||||||
|
],
|
||||||
|
TPL_NEW_FORMAT: [
|
||||||
|
CallbackQueryHandler(tpl_new_format, pattern=r"^tplfmt:")
|
||||||
|
],
|
||||||
|
TPL_NEW_CONTENT: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_content)
|
||||||
|
],
|
||||||
|
TPL_NEW_KB: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_kb)
|
||||||
|
],
|
||||||
|
TPL_CONFIRM_DELETE: [
|
||||||
|
CallbackQueryHandler(tpl_delete_ok, pattern=r"^tpldelok:"),
|
||||||
|
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fallbacks=[CommandHandler("start", start)],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_handler(CommandHandler("start", start))
|
||||||
|
app.add_handler(post_conv)
|
||||||
|
app.add_handler(tpl_conv)
|
||||||
|
app.add_handler(CommandHandler("tpl_list", tpl_list))
|
||||||
|
|
||||||
|
# Запуск бота
|
||||||
|
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical(f"Critical error in main: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# -------- Вспомогательные функции для шаблонов ---------
|
||||||
|
|
||||||
|
async def _render_tpl_list(q_or_msg: Update | CallbackContext, uid: int, page: int) -> int:
|
||||||
|
"""Отображение списка шаблонов с пагинацией."""
|
||||||
|
try:
|
||||||
|
total = await count_templates(uid)
|
||||||
|
offset = page * PAGE_SIZE
|
||||||
|
tpls = await list_templates(uid, limit=PAGE_SIZE, offset=offset)
|
||||||
|
|
||||||
|
if not tpls:
|
||||||
|
if page == 0:
|
||||||
|
if hasattr(q_or_msg, "edit_message_text"):
|
||||||
|
await q_or_msg.edit_message_text("Шаблонов пока нет. Создай через /tpl_new.")
|
||||||
|
else:
|
||||||
|
await q_or_msg.reply_text("Шаблонов пока нет. Создай через /tpl_new.")
|
||||||
|
return ENTER_TEXT
|
||||||
|
else:
|
||||||
|
return await _render_tpl_list(q_or_msg, uid, 0)
|
||||||
|
|
||||||
|
kb = []
|
||||||
|
for t in tpls:
|
||||||
|
kb.append([
|
||||||
|
InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"),
|
||||||
|
InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}")
|
||||||
|
])
|
||||||
|
|
||||||
|
nav = []
|
||||||
|
if page > 0:
|
||||||
|
nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}"))
|
||||||
|
if offset + PAGE_SIZE < total:
|
||||||
|
nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}"))
|
||||||
|
if nav:
|
||||||
|
kb.append(nav)
|
||||||
|
kb.append([InlineKeyboardButton("Отмена", callback_data="tpl:cancel")])
|
||||||
|
|
||||||
|
text = f"Шаблоны (стр. {page+1}/{(total-1)//PAGE_SIZE + 1}):"
|
||||||
|
if hasattr(q_or_msg, "edit_message_text"):
|
||||||
|
await q_or_msg.edit_message_text(text, reply_markup=InlineKeyboardMarkup(kb))
|
||||||
|
else:
|
||||||
|
await q_or_msg.reply_text(text, reply_markup=InlineKeyboardMarkup(kb))
|
||||||
|
return SELECT_TEMPLATE
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error rendering template list: {e}")
|
||||||
|
if hasattr(q_or_msg, "edit_message_text"):
|
||||||
|
await q_or_msg.edit_message_text("Ошибка при загрузке списка шаблонов")
|
||||||
|
else:
|
||||||
|
await q_or_msg.reply_text("Ошибка при загрузке списка шаблонов")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def _apply_template_and_confirm(q_or_msg: Union[CallbackQuery, Message], uid: int, name: str, ctx_vars: dict) -> int:
|
||||||
|
"""Применение шаблона к текущему посту."""
|
||||||
|
try:
|
||||||
|
rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars)
|
||||||
|
session[uid].update({
|
||||||
|
"type": rendered["type"],
|
||||||
|
"text": rendered["text"],
|
||||||
|
"keyboard": {"rows": rendered["keyboard_rows"]} if rendered.get("keyboard_rows") else None,
|
||||||
|
"parse_mode": rendered.get("parse_mode") or session[uid].get("parse_mode") or "HTML"
|
||||||
|
})
|
||||||
|
|
||||||
|
kb = [
|
||||||
|
[InlineKeyboardButton("Отправить сейчас", callback_data="send:now")],
|
||||||
|
[InlineKeyboardButton("Запланировать", callback_data="send:schedule")]
|
||||||
|
]
|
||||||
|
markup = InlineKeyboardMarkup(kb)
|
||||||
|
|
||||||
|
if isinstance(q_or_msg, CallbackQuery):
|
||||||
|
await q_or_msg.edit_message_text(
|
||||||
|
"Шаблон применён. Как публикуем?",
|
||||||
|
reply_markup=markup
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await cast(Message, q_or_msg).reply_text(
|
||||||
|
"Шаблон применён. Как публикуем?",
|
||||||
|
reply_markup=markup
|
||||||
|
)
|
||||||
|
|
||||||
|
return CONFIRM_SEND
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error applying template: {e}")
|
||||||
|
if isinstance(q_or_msg, CallbackQuery):
|
||||||
|
await q_or_msg.edit_message_text("Ошибка при применении шаблона")
|
||||||
|
else:
|
||||||
|
await cast(Message, q_or_msg).reply_text("Ошибка при применении шаблона")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def _render_preview_and_confirm(q_or_msg: Update | CallbackContext, uid: int, name: str, ctx_vars: dict) -> int:
|
||||||
|
"""Рендеринг предпросмотра шаблона."""
|
||||||
|
try:
|
||||||
|
rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars)
|
||||||
|
text = rendered["text"]
|
||||||
|
parse_mode = rendered.get("parse_mode") or session.get(uid, {}).get("parse_mode") or "HTML"
|
||||||
|
|
||||||
|
preview_text = f"Предпросмотр:\n\n{text[:3500]}"
|
||||||
|
|
||||||
|
if hasattr(q_or_msg, "edit_message_text"):
|
||||||
|
await q_or_msg.edit_message_text(preview_text, parse_mode=parse_mode)
|
||||||
|
else:
|
||||||
|
await q_or_msg.reply_text(preview_text, parse_mode=parse_mode)
|
||||||
|
|
||||||
|
kb = [
|
||||||
|
[InlineKeyboardButton("✅ Использовать", callback_data="pv:use")],
|
||||||
|
[InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")],
|
||||||
|
[InlineKeyboardButton("Отмена", callback_data="tpl:cancel")]
|
||||||
|
]
|
||||||
|
markup = InlineKeyboardMarkup(kb)
|
||||||
|
|
||||||
|
if hasattr(q_or_msg, "reply_text"):
|
||||||
|
await q_or_msg.reply_text("Что дальше?", reply_markup=markup)
|
||||||
|
elif hasattr(q_or_msg, "message") and q_or_msg.message:
|
||||||
|
await q_or_msg.message.reply_text("Что дальше?", reply_markup=markup)
|
||||||
|
|
||||||
|
return PREVIEW_CONFIRM
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in preview render: {e}")
|
||||||
|
if hasattr(q_or_msg, "edit_message_text"):
|
||||||
|
await q_or_msg.edit_message_text("Ошибка при рендеринге шаблона")
|
||||||
|
else:
|
||||||
|
await q_or_msg.reply_text("Ошибка при рендеринге шаблона")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
# -------- Обработчики шаблонов ---------
|
||||||
|
|
||||||
|
async def choose_template_open(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Открытие списка шаблонов."""
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
context.user_data["tpl_page"] = 0
|
||||||
|
return await _render_tpl_list(q, uid, page=0)
|
||||||
|
|
||||||
|
async def choose_template_navigate(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Навигация по списку шаблонов."""
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
_, page_s = q.data.split(":")
|
||||||
|
page = int(page_s)
|
||||||
|
context.user_data["tpl_page"] = page
|
||||||
|
return await _render_tpl_list(q, uid, page)
|
||||||
|
|
||||||
|
async def choose_template_apply(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Применение выбранного шаблона."""
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
try:
|
||||||
|
name = q.data.split(":")[1]
|
||||||
|
tpl = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||||
|
required = set(tpl.get("_required", []))
|
||||||
|
if required:
|
||||||
|
context.user_data["preview"] = {"name": name, "provided": {}, "missing": list(required)}
|
||||||
|
await q.edit_message_text(
|
||||||
|
"Шаблон требует переменные: " + ", ".join(sorted(required)) +
|
||||||
|
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||||
|
)
|
||||||
|
return PREVIEW_VARS
|
||||||
|
return await _apply_template_and_confirm(q, uid, name, {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error applying template: {e}")
|
||||||
|
await q.edit_message_text("Ошибка при применении шаблона")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def choose_template_preview(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Предпросмотр шаблона."""
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
uid = update.effective_user.id
|
||||||
|
try:
|
||||||
|
name = q.data.split(":")[1]
|
||||||
|
tpl = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||||||
|
required = set(tpl.get("_required", []))
|
||||||
|
context.user_data["preview"] = {"name": name, "provided": {}, "missing": list(required)}
|
||||||
|
if required:
|
||||||
|
await q.edit_message_text(
|
||||||
|
"Для предпросмотра нужны переменные: " + ", ".join(sorted(required)) +
|
||||||
|
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||||||
|
)
|
||||||
|
return PREVIEW_VARS
|
||||||
|
return await _render_preview_and_confirm(q, uid, name, {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error previewing template: {e}")
|
||||||
|
await q.edit_message_text("Ошибка при предпросмотре шаблона")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def choose_template_cancel(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Отмена выбора шаблона."""
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
await q.edit_message_text("Отправь текст сообщения или введи #имя для шаблона.")
|
||||||
|
return ENTER_TEXT
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
395
app/bots/editor_bot.py.new
Normal file
395
app/bots/editor_bot.py.new
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import shlex
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, List, Any
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from telegram.ext import (
|
||||||
|
Application, CommandHandler, MessageHandler, ConversationHandler,
|
||||||
|
CallbackQueryHandler, CallbackContext, filters,
|
||||||
|
)
|
||||||
|
from telegram.error import TelegramError
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.tasks.senders import send_post_task
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
from app.models.channel import Channel
|
||||||
|
from app.models.post import PostType
|
||||||
|
from app.services.templates import (
|
||||||
|
render_template_by_name, list_templates, count_templates,
|
||||||
|
create_template, delete_template, required_variables_of_template,
|
||||||
|
)
|
||||||
|
from jinja2 import TemplateError
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Константы
|
||||||
|
MAX_MESSAGE_LENGTH = 4096
|
||||||
|
PAGE_SIZE = 8
|
||||||
|
SESSION_TIMEOUT = 3600 # 1 час
|
||||||
|
ALLOWED_URL_SCHEMES = ('http', 'https', 't.me')
|
||||||
|
|
||||||
|
# Состояния диалога
|
||||||
|
(
|
||||||
|
CHOOSE_CHANNEL, CHOOSE_TYPE, CHOOSE_FORMAT, ENTER_TEXT, ENTER_MEDIA, EDIT_KEYBOARD,
|
||||||
|
CONFIRM_SEND, ENTER_SCHEDULE, SELECT_TEMPLATE,
|
||||||
|
PREVIEW_VARS, PREVIEW_CONFIRM,
|
||||||
|
TPL_NEW_NAME, TPL_NEW_TYPE, TPL_NEW_FORMAT, TPL_NEW_CONTENT, TPL_NEW_KB, TPL_CONFIRM_DELETE,
|
||||||
|
) = range(16)
|
||||||
|
|
||||||
|
# In-memory сессии с метаданными
|
||||||
|
session: Dict[int, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def validate_url(url: str) -> bool:
|
||||||
|
"""Проверка безопасности URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Строка URL для проверки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если URL безопасен, False в противном случае
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = urlparse(url)
|
||||||
|
return all([
|
||||||
|
result.scheme in ALLOWED_URL_SCHEMES,
|
||||||
|
result.netloc,
|
||||||
|
len(url) < 2048 # Максимальная длина URL
|
||||||
|
])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"URL validation failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def validate_message_length(text: str) -> bool:
|
||||||
|
"""Проверка длины сообщения согласно лимитам Telegram.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст для проверки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если длина в пределах лимита
|
||||||
|
"""
|
||||||
|
return len(text) <= MAX_MESSAGE_LENGTH
|
||||||
|
|
||||||
|
def update_session_activity(uid: int) -> None:
|
||||||
|
"""Обновление времени последней активности в сессии."""
|
||||||
|
if uid in session:
|
||||||
|
session[uid]['last_activity'] = time.time()
|
||||||
|
|
||||||
|
def cleanup_old_sessions() -> None:
|
||||||
|
"""Периодическая очистка старых сессий."""
|
||||||
|
current_time = time.time()
|
||||||
|
for uid in list(session.keys()):
|
||||||
|
if current_time - session[uid].get('last_activity', 0) > SESSION_TIMEOUT:
|
||||||
|
logger.info(f"Cleaning up session for user {uid}")
|
||||||
|
del session[uid]
|
||||||
|
|
||||||
|
def parse_template_invocation(s: str) -> tuple[str, dict]:
|
||||||
|
"""Разбор строки вызова шаблона.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
s: Строка в формате #template_name key1=value1 key2=value2
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (имя_шаблона, словарь_параметров)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Если неверный формат строки
|
||||||
|
"""
|
||||||
|
s = s.strip()
|
||||||
|
if not s.startswith("#"):
|
||||||
|
raise ValueError("Имя шаблона должно начинаться с #")
|
||||||
|
parts = shlex.split(s)
|
||||||
|
if not parts:
|
||||||
|
raise ValueError("Пустой шаблон")
|
||||||
|
name = parts[0][1:]
|
||||||
|
args: dict[str, str] = {}
|
||||||
|
for tok in parts[1:]:
|
||||||
|
if "=" in tok:
|
||||||
|
k, v = tok.split("=", 1)
|
||||||
|
args[k.strip()] = v.strip()
|
||||||
|
return name, args
|
||||||
|
|
||||||
|
def parse_key_value_lines(text: str) -> dict:
|
||||||
|
"""Парсинг строк формата key=value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Строки в формате key=value или key="quoted value"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Словарь параметров
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
if "\n" in text:
|
||||||
|
for line in text.splitlines():
|
||||||
|
if "=" in line:
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
v = v.strip().strip('"')
|
||||||
|
if k.strip(): # Проверка на пустой ключ
|
||||||
|
out[k.strip()] = v
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
for tok in shlex.split(text):
|
||||||
|
if "=" in tok:
|
||||||
|
k, v = tok.split("=", 1)
|
||||||
|
if k.strip(): # Проверка на пустой ключ
|
||||||
|
out[k.strip()] = v
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"Error parsing key-value line: {e}")
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
# -------- Команды верхнего уровня ---------
|
||||||
|
|
||||||
|
async def start(update: Update, context: CallbackContext) -> None:
|
||||||
|
"""Обработчик команды /start."""
|
||||||
|
update_session_activity(update.effective_user.id)
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Привет! Я редактор. Команды:\n"
|
||||||
|
"/newpost — мастер поста\n"
|
||||||
|
"/tpl_new — создать шаблон\n"
|
||||||
|
"/tpl_list — список шаблонов"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def newpost(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Начало создания нового поста."""
|
||||||
|
uid = update.effective_user.id
|
||||||
|
update_session_activity(uid)
|
||||||
|
|
||||||
|
session[uid] = {'last_activity': time.time()}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session_maker() as s:
|
||||||
|
res = await s.execute(select(Channel).where(Channel.owner_id == uid).limit(50))
|
||||||
|
channels = list(res.scalars())
|
||||||
|
|
||||||
|
if not channels:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Пока нет каналов. Добавь через админку или команду /add_channel (в разработке)."
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
kb = [
|
||||||
|
[InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")]
|
||||||
|
for ch in channels
|
||||||
|
]
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Выбери канал для публикации:",
|
||||||
|
reply_markup=InlineKeyboardMarkup(kb)
|
||||||
|
)
|
||||||
|
return CHOOSE_CHANNEL
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in newpost: {e}")
|
||||||
|
await update.message.reply_text("Произошла ошибка. Попробуйте позже.")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def choose_channel(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Обработка выбора канала."""
|
||||||
|
q = update.callback_query
|
||||||
|
await q.answer()
|
||||||
|
|
||||||
|
uid = update.effective_user.id
|
||||||
|
update_session_activity(uid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ch_id = int(q.data.split(":")[1])
|
||||||
|
session[uid]["channel_id"] = ch_id
|
||||||
|
|
||||||
|
kb = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("Текст", callback_data="type:text"),
|
||||||
|
InlineKeyboardButton("Фото", callback_data="type:photo")
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("Видео", callback_data="type:video"),
|
||||||
|
InlineKeyboardButton("GIF", callback_data="type:animation")
|
||||||
|
],
|
||||||
|
]
|
||||||
|
await q.edit_message_text(
|
||||||
|
"Тип поста:",
|
||||||
|
reply_markup=InlineKeyboardMarkup(kb)
|
||||||
|
)
|
||||||
|
return CHOOSE_TYPE
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await q.edit_message_text("Ошибка: неверный формат ID канала")
|
||||||
|
return ConversationHandler.END
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in choose_channel: {e}")
|
||||||
|
await q.edit_message_text("Произошла ошибка. Попробуйте заново.")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
# ... [Остальные функции обновляются аналогично] ...
|
||||||
|
|
||||||
|
async def enter_schedule(update: Update, context: CallbackContext) -> int:
|
||||||
|
"""Обработка ввода времени для отложенной публикации."""
|
||||||
|
uid = update.effective_user.id
|
||||||
|
update_session_activity(uid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
if when < datetime.now():
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Дата должна быть в будущем. Укажите корректное время в формате YYYY-MM-DD HH:MM"
|
||||||
|
)
|
||||||
|
return ENTER_SCHEDULE
|
||||||
|
|
||||||
|
await _dispatch_with_eta(uid, when)
|
||||||
|
await update.message.reply_text("Задача запланирована.")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Неверный формат даты. Используйте формат YYYY-MM-DD HH:MM"
|
||||||
|
)
|
||||||
|
return ENTER_SCHEDULE
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error scheduling post: {e}")
|
||||||
|
await update.message.reply_text("Ошибка планирования. Попробуйте позже.")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def _dispatch_with_eta(uid: int, when: datetime) -> None:
|
||||||
|
"""Отправка отложенного поста."""
|
||||||
|
data = session.get(uid)
|
||||||
|
if not data:
|
||||||
|
raise ValueError("Сессия потеряна")
|
||||||
|
|
||||||
|
token = settings.editor_bot_token
|
||||||
|
try:
|
||||||
|
payload = build_payload(
|
||||||
|
ptype=data.get("type"),
|
||||||
|
text=data.get("text"),
|
||||||
|
media_file_id=data.get("media_file_id"),
|
||||||
|
parse_mode=data.get("parse_mode") or "HTML",
|
||||||
|
keyboard=data.get("keyboard"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверка длины сообщения
|
||||||
|
if not validate_message_length(payload.get("text", "")):
|
||||||
|
raise ValueError("Превышен максимальный размер сообщения")
|
||||||
|
|
||||||
|
# Проверка URL в клавиатуре
|
||||||
|
if keyboard := payload.get("keyboard"):
|
||||||
|
for row in keyboard.get("rows", []):
|
||||||
|
for btn in row:
|
||||||
|
if "url" in btn and not validate_url(btn["url"]):
|
||||||
|
raise ValueError(f"Небезопасный URL: {btn['url']}")
|
||||||
|
|
||||||
|
send_post_task.apply_async(
|
||||||
|
args=[token, data["channel_id"], payload],
|
||||||
|
eta=when
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in _dispatch_with_eta: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Инициализация и запуск бота."""
|
||||||
|
try:
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
level=logging.INFO
|
||||||
|
)
|
||||||
|
|
||||||
|
# Инициализация планировщика
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
scheduler.add_job(cleanup_old_sessions, 'interval', minutes=30)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
app = Application.builder().token(settings.editor_bot_token).build()
|
||||||
|
|
||||||
|
# Регистрация обработчиков
|
||||||
|
post_conv = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("newpost", newpost)],
|
||||||
|
states={
|
||||||
|
CHOOSE_CHANNEL: [CallbackQueryHandler(choose_channel, pattern=r"^channel:")],
|
||||||
|
CHOOSE_TYPE: [CallbackQueryHandler(choose_type, pattern=r"^type:")],
|
||||||
|
CHOOSE_FORMAT: [CallbackQueryHandler(choose_format, pattern=r"^fmt:")],
|
||||||
|
ENTER_TEXT: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text),
|
||||||
|
CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$"),
|
||||||
|
],
|
||||||
|
SELECT_TEMPLATE: [
|
||||||
|
CallbackQueryHandler(choose_template_apply, pattern=r"^tpluse:"),
|
||||||
|
CallbackQueryHandler(choose_template_preview, pattern=r"^tplprev:"),
|
||||||
|
CallbackQueryHandler(choose_template_navigate, pattern=r"^tplpage:"),
|
||||||
|
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||||
|
],
|
||||||
|
PREVIEW_VARS: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars)
|
||||||
|
],
|
||||||
|
PREVIEW_CONFIRM: [
|
||||||
|
CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"),
|
||||||
|
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||||
|
],
|
||||||
|
ENTER_MEDIA: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_media)
|
||||||
|
],
|
||||||
|
EDIT_KEYBOARD: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard)
|
||||||
|
],
|
||||||
|
CONFIRM_SEND: [
|
||||||
|
CallbackQueryHandler(confirm_send, pattern=r"^send:")
|
||||||
|
],
|
||||||
|
ENTER_SCHEDULE: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fallbacks=[CommandHandler("start", start)],
|
||||||
|
)
|
||||||
|
|
||||||
|
tpl_conv = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("tpl_new", tpl_new_start)],
|
||||||
|
states={
|
||||||
|
TPL_NEW_NAME: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_name)
|
||||||
|
],
|
||||||
|
TPL_NEW_TYPE: [
|
||||||
|
CallbackQueryHandler(tpl_new_type, pattern=r"^tpltype:")
|
||||||
|
],
|
||||||
|
TPL_NEW_FORMAT: [
|
||||||
|
CallbackQueryHandler(tpl_new_format, pattern=r"^tplfmt:")
|
||||||
|
],
|
||||||
|
TPL_NEW_CONTENT: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_content)
|
||||||
|
],
|
||||||
|
TPL_NEW_KB: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_kb)
|
||||||
|
],
|
||||||
|
TPL_CONFIRM_DELETE: [
|
||||||
|
CallbackQueryHandler(tpl_delete_ok, pattern=r"^tpldelok:"),
|
||||||
|
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fallbacks=[CommandHandler("start", start)],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_handler(CommandHandler("start", start))
|
||||||
|
app.add_handler(post_conv)
|
||||||
|
app.add_handler(tpl_conv)
|
||||||
|
app.add_handler(CommandHandler("tpl_list", tpl_list))
|
||||||
|
|
||||||
|
# Запуск бота
|
||||||
|
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical(f"Critical error in main: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
15
app/bots/session_manager.py
Normal file
15
app/bots/session_manager.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from typing import Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
def __init__(self):
|
||||||
|
self._sessions: Dict[int, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def get_session(self, user_id: int) -> Dict[str, Any]:
|
||||||
|
return self._sessions.setdefault(user_id, {})
|
||||||
|
|
||||||
|
def update_session(self, user_id: int, data: Dict[str, Any]) -> None:
|
||||||
|
self._sessions[user_id] = {**self.get_session(user_id), **data}
|
||||||
|
|
||||||
|
def clear_session(self, user_id: int) -> None:
|
||||||
|
self._sessions.pop(user_id, None)
|
||||||
18
app/bots/states/base.py
Normal file
18
app/bots/states/base.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from enum import IntEnum
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import CallbackContext
|
||||||
|
|
||||||
|
class State(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def handle(self, update: Update, context: CallbackContext) -> int:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class BotStates(IntEnum):
|
||||||
|
CHOOSE_CHANNEL = 0
|
||||||
|
CHOOSE_TYPE = 1
|
||||||
|
ENTER_TEXT = 2
|
||||||
|
ENTER_MEDIA = 3
|
||||||
|
EDIT_KEYBOARD = 4
|
||||||
|
CONFIRM_SEND = 5
|
||||||
|
ENTER_SCHEDULE = 6
|
||||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
30
app/core/config.py
Normal file
30
app/core/config.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# app/core/config.py
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# общие
|
||||||
|
env: str = "dev"
|
||||||
|
tz: str = "Asia/Seoul"
|
||||||
|
|
||||||
|
# БД (async/sync)
|
||||||
|
database_url: str = Field(alias="DATABASE_URL")
|
||||||
|
database_url_sync: str | None = Field(default=None, alias="DATABASE_URL_SYNC")
|
||||||
|
|
||||||
|
# Redis/Celery
|
||||||
|
redis_url: str = Field(alias="REDIS_URL")
|
||||||
|
celery_broker_url: str = Field(alias="CELERY_BROKER_URL")
|
||||||
|
celery_result_backend: str = Field(alias="CELERY_RESULT_BACKEND")
|
||||||
|
|
||||||
|
# Telegram / секреты
|
||||||
|
editor_bot_token: str = Field(alias="EDITOR_BOT_TOKEN")
|
||||||
|
secret_key: str | None = Field(default=None, alias="SECRET_KEY")
|
||||||
|
|
||||||
|
# Конфиг загрузки
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
extra="ignore", # игнорировать лишние переменные (DB_HOST, DB_USER и т.п.)
|
||||||
|
case_sensitive=False, # имена переменных без учета регистра
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
0
app/core/security.py
Normal file
0
app/core/security.py
Normal file
0
app/db/__init__.py
Normal file
0
app/db/__init__.py
Normal file
3
app/db/base.py
Normal file
3
app/db/base.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.db.session import Base # импортируем сам Base
|
||||||
|
# Импортируем все модели, чтобы они зарегистрировались в metadata:
|
||||||
|
from app.models import user, bot, channel, keyboard, post, audit, templates # noqa: F401
|
||||||
43
app/db/session.py
Normal file
43
app/db/session.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from typing import AsyncGenerator
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
|
from app.core.config import settings
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
future=True,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Единая declarative Base для всех моделей."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# каноничное имя
|
||||||
|
async_session_maker = async_sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# алиас для обратной совместимости (если где-то используется)
|
||||||
|
AsyncSessionLocal = async_session_maker
|
||||||
|
|
||||||
|
# DI-генератор для FastAPI
|
||||||
|
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
# тоже оставим старое имя-обёртку, если где-то подключено
|
||||||
|
get_session = get_async_session
|
||||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
15
app/models/audit.py
Normal file
15
app/models/audit.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import ForeignKey, String, JSON, func, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
action: Mapped[str] = mapped_column(String(64))
|
||||||
|
payload: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
17
app/models/bot.py
Normal file
17
app/models/bot.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import ForeignKey, String, func, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
class Bot(Base):
|
||||||
|
__tablename__ = "bots"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
name: Mapped[str] = mapped_column(String(64))
|
||||||
|
username: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
token_enc: Mapped[str] = mapped_column(String(512))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
owner = relationship("User")
|
||||||
26
app/models/channel.py
Normal file
26
app/models/channel.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import ForeignKey, String, BigInteger, Boolean, UniqueConstraint, func, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
class Channel(Base):
|
||||||
|
__tablename__ = "channels"
|
||||||
|
__table_args__ = (UniqueConstraint("owner_id", "chat_id", name="uq_owner_chat"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
chat_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
||||||
|
title: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
username: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
owner = relationship("User")
|
||||||
|
|
||||||
|
class BotChannel(Base):
|
||||||
|
__tablename__ = "bot_channels"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
bot_id: Mapped[int] = mapped_column(ForeignKey("bots.id", ondelete="CASCADE"))
|
||||||
|
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
|
||||||
|
can_post: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
20
app/models/keyboard.py
Normal file
20
app/models/keyboard.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
class Keyboard(Base):
|
||||||
|
__tablename__ = "keyboards"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
name: Mapped[str] = mapped_column(String(64))
|
||||||
|
|
||||||
|
class KeyboardButton(Base):
|
||||||
|
__tablename__ = "keyboard_buttons"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
keyboard_id: Mapped[int] = mapped_column(ForeignKey("keyboards.id", ondelete="CASCADE"))
|
||||||
|
text: Mapped[str] = mapped_column(String(128))
|
||||||
|
url: Mapped[str] = mapped_column(String(512))
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
52
app/models/post.py
Normal file
52
app/models/post.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import ForeignKey, String, Enum, DateTime, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
class PostType(str, enum.Enum):
|
||||||
|
text = "text"
|
||||||
|
photo = "photo"
|
||||||
|
video = "video"
|
||||||
|
animation = "animation"
|
||||||
|
|
||||||
|
class PostStatus(str, enum.Enum):
|
||||||
|
draft = "draft"
|
||||||
|
scheduled = "scheduled"
|
||||||
|
sent = "sent"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
class Post(Base):
|
||||||
|
__tablename__ = "posts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
bot_id: Mapped[Optional[int]] = mapped_column(ForeignKey("bots.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
|
||||||
|
|
||||||
|
type: Mapped[PostType] = mapped_column(Enum(PostType))
|
||||||
|
text: Mapped[Optional[str]] = mapped_column(String(4096))
|
||||||
|
media_file_id: Mapped[Optional[str]] = mapped_column(String(512))
|
||||||
|
parse_mode: Mapped[Optional[str]] = mapped_column(String(16))
|
||||||
|
keyboard_id: Mapped[Optional[int]] = mapped_column(ForeignKey("keyboards.id", ondelete="SET NULL"))
|
||||||
|
|
||||||
|
status: Mapped[PostStatus] = mapped_column(Enum(PostStatus), default=PostStatus.draft)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
class ScheduleStatus(str, enum.Enum):
|
||||||
|
pending = "pending"
|
||||||
|
done = "done"
|
||||||
|
cancelled = "cancelled"
|
||||||
|
|
||||||
|
class Schedule(Base):
|
||||||
|
__tablename__ = "schedules"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"))
|
||||||
|
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64))
|
||||||
|
celery_task_id: Mapped[Optional[str]] = mapped_column(String(128))
|
||||||
|
status: Mapped[ScheduleStatus] = mapped_column(Enum(ScheduleStatus), default=ScheduleStatus.pending)
|
||||||
33
app/models/templates.py
Normal file
33
app/models/templates.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, String, UniqueConstraint, func, Text, JSON, Boolean
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.sql.sqltypes import Enum as EnumType # Явный импорт Enum для типизации
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.post import PostType
|
||||||
|
from enum import Enum as PyEnum
|
||||||
|
|
||||||
|
class TemplateVisibility(str, PyEnum):
|
||||||
|
private = "private"
|
||||||
|
org = "org"
|
||||||
|
public = "public"
|
||||||
|
|
||||||
|
class Template(Base):
|
||||||
|
__tablename__ = "templates"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("owner_id", "name", name="uq_template_owner_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64))
|
||||||
|
title: Mapped[Optional[str]] = mapped_column(String(128))
|
||||||
|
type: Mapped[PostType] = mapped_column(EnumType(PostType), default=PostType.text)
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
keyboard_tpl: Mapped[Optional[list[dict]]] = mapped_column(JSON, nullable=True)
|
||||||
|
parse_mode: Mapped[Optional[str]] = mapped_column(String(16), default="HTML")
|
||||||
|
visibility: Mapped[TemplateVisibility] = mapped_column(EnumType(TemplateVisibility), default=TemplateVisibility.private)
|
||||||
|
is_archived: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
17
app/models/user.py
Normal file
17
app/models/user.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import BigInteger, String, func, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
tg_user_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
|
||||||
|
username: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
role: Mapped[str] = mapped_column(String(16), default="user")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<User(id={self.id}, tg_user_id={self.tg_user_id}, username={self.username}, role={self.role}, created_at={self.created_at})>"
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
10
app/services/crypto.py
Normal file
10
app/services/crypto.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
def encrypt_token(token: str, secret: str) -> str:
|
||||||
|
# TODO: заменить на реальное шифрование (Fernet) — здесь простая обёртка
|
||||||
|
raw = f"{secret}:{token}".encode()
|
||||||
|
return base64.urlsafe_b64encode(raw).decode()
|
||||||
|
|
||||||
|
def decrypt_token(token_enc: str, secret: str) -> str:
|
||||||
|
data = base64.urlsafe_b64decode(token_enc.encode()).decode()
|
||||||
|
return data.split(":", 1)[1]
|
||||||
19
app/services/telegram.py
Normal file
19
app/services/telegram.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
def make_keyboard_payload(buttons: Iterable[tuple[str, str]] | None):
|
||||||
|
if not buttons:
|
||||||
|
return None
|
||||||
|
rows = [[{"text": t, "url": u}] for t, u in buttons]
|
||||||
|
return {"rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def build_payload(ptype: str, text: str | None, media_file_id: str | None,
|
||||||
|
parse_mode: str | None, keyboard: dict | None) -> dict:
|
||||||
|
# ptype: "text" | "photo" | "video" | "animation"
|
||||||
|
return {
|
||||||
|
"type": ptype,
|
||||||
|
"text": text,
|
||||||
|
"media_file_id": media_file_id,
|
||||||
|
"parse_mode": parse_mode,
|
||||||
|
"keyboard": keyboard,
|
||||||
|
}
|
||||||
141
app/services/templates.py
Normal file
141
app/services/templates.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import Any, Optional, Iterable, Set
|
||||||
|
|
||||||
|
from jinja2.sandbox import SandboxedEnvironment
|
||||||
|
from jinja2 import StrictUndefined, TemplateError, Environment, meta
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
from app.models.templates import Template
|
||||||
|
|
||||||
|
# ---- Jinja2 окружение для рендера (sandbox) ----
|
||||||
|
_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
|
||||||
|
|
||||||
|
# Безопасные фильтры для подстановок
|
||||||
|
import html as _py_html
|
||||||
|
|
||||||
|
def escape_mdv2(value: str) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
s = str(value)
|
||||||
|
for ch in ["_","*","[","]","(",")","~","`",">","#","+","-","=","|","{","}",".","!"]:
|
||||||
|
s = s.replace(ch, f"\{ch}")
|
||||||
|
return s
|
||||||
|
|
||||||
|
def escape_html(value: str) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return _py_html.escape(str(value), quote=False)
|
||||||
|
|
||||||
|
_env.filters["mdv2"] = escape_mdv2
|
||||||
|
_env.filters["html"] = escape_html
|
||||||
|
_env.filters["upper"] = str.upper
|
||||||
|
_env.filters["lower"] = str.lower
|
||||||
|
|
||||||
|
# ---- Служебное окружение для анализа AST шаблона (поиск переменных) ----
|
||||||
|
_ast_env = Environment()
|
||||||
|
|
||||||
|
# -------- БД операции --------
|
||||||
|
async def list_templates(owner_id: int, limit: int = 10, offset: int = 0, q: str | None = None) -> list[Template]:
|
||||||
|
async with async_session_maker() as s:
|
||||||
|
stmt = select(Template).where(Template.owner_id == owner_id, Template.is_archived.is_(False))
|
||||||
|
if q:
|
||||||
|
# простейший поиск по name/title (SQLite/MariaDB — регистр может зависеть от коллаций)
|
||||||
|
like = f"%{q}%"
|
||||||
|
from sqlalchemy import or_
|
||||||
|
stmt = stmt.where(or_(Template.name.ilike(like), Template.title.ilike(like)))
|
||||||
|
stmt = stmt.order_by(Template.updated_at.desc()).limit(limit).offset(offset)
|
||||||
|
res = await s.execute(stmt)
|
||||||
|
return list(res.scalars())
|
||||||
|
|
||||||
|
async def count_templates(owner_id: int, q: str | None = None) -> int:
|
||||||
|
async with async_session_maker() as s:
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
stmt = select(func.count()).select_from(Template).where(Template.owner_id == owner_id, Template.is_archived.is_(False))
|
||||||
|
if q:
|
||||||
|
like = f"%{q}%"
|
||||||
|
stmt = stmt.where(or_(Template.name.ilike(like), Template.title.ilike(like)))
|
||||||
|
res = await s.execute(stmt)
|
||||||
|
return int(res.scalar_one())
|
||||||
|
|
||||||
|
async def get_template_by_name(owner_id: int, name: str) -> Optional[Template]:
|
||||||
|
async with async_session_maker() as s:
|
||||||
|
res = await s.execute(
|
||||||
|
select(Template)
|
||||||
|
.where(Template.owner_id == owner_id, Template.name == name, Template.is_archived.is_(False))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return res.scalars().first()
|
||||||
|
|
||||||
|
async def create_template(owner_id: int, name: str, title: str | None, type_: str,
|
||||||
|
content: str, keyboard_tpl: list[dict] | None, parse_mode: str | None) -> int:
|
||||||
|
async with async_session_maker() as s:
|
||||||
|
tpl = Template(
|
||||||
|
owner_id=owner_id,
|
||||||
|
name=name,
|
||||||
|
title=title,
|
||||||
|
type=type_,
|
||||||
|
content=content,
|
||||||
|
keyboard_tpl=keyboard_tpl,
|
||||||
|
parse_mode=parse_mode or "HTML",
|
||||||
|
)
|
||||||
|
s.add(tpl)
|
||||||
|
await s.commit()
|
||||||
|
await s.refresh(tpl)
|
||||||
|
return tpl.id
|
||||||
|
|
||||||
|
async def delete_template(owner_id: int, tpl_id: int) -> bool:
|
||||||
|
async with async_session_maker() as s:
|
||||||
|
res = await s.execute(select(Template).where(Template.id == tpl_id, Template.owner_id == owner_id))
|
||||||
|
tpl = res.scalars().first()
|
||||||
|
if not tpl:
|
||||||
|
return False
|
||||||
|
await s.delete(tpl)
|
||||||
|
await s.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -------- Рендер и анализ переменных --------
|
||||||
|
|
||||||
|
def _render_text(src: str, ctx: dict[str, Any]) -> str:
|
||||||
|
return _env.from_string(src).render(**ctx)
|
||||||
|
|
||||||
|
def _render_keyboard(kb_tpl: list[dict] | None, ctx: dict[str, Any]) -> list[list[dict]] | None:
|
||||||
|
if not kb_tpl:
|
||||||
|
return None
|
||||||
|
rows: list[list[dict]] = []
|
||||||
|
for btn in kb_tpl:
|
||||||
|
text = _render_text(str(btn.get("text", "")), ctx)
|
||||||
|
url = _render_text(str(btn.get("url", "")), ctx)
|
||||||
|
rows.append([{"text": text, "url": url}])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _collect_vars_from_str(src: str) -> Set[str]:
|
||||||
|
try:
|
||||||
|
ast = _ast_env.parse(src or "")
|
||||||
|
vars_ = meta.find_undeclared_variables(ast)
|
||||||
|
# исключим служебные имена (loop, etc.) — обычно они не используются как внешние
|
||||||
|
return {v for v in vars_ if not v.startswith("loop")}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def required_variables_of_template(content: str, keyboard_tpl: list[dict] | None) -> Set[str]:
|
||||||
|
req = _collect_vars_from_str(content)
|
||||||
|
if keyboard_tpl:
|
||||||
|
for btn in keyboard_tpl:
|
||||||
|
req |= _collect_vars_from_str(str(btn.get("text", "")))
|
||||||
|
req |= _collect_vars_from_str(str(btn.get("url", "")))
|
||||||
|
return req
|
||||||
|
|
||||||
|
async def render_template_by_name(owner_id: int, name: str, ctx: dict[str, Any]) -> dict:
|
||||||
|
tpl = await get_template_by_name(owner_id, name)
|
||||||
|
if not tpl:
|
||||||
|
raise LookupError("Template not found")
|
||||||
|
content = _render_text(tpl.content, ctx)
|
||||||
|
keyboard_rows = _render_keyboard(tpl.keyboard_tpl, ctx)
|
||||||
|
return {
|
||||||
|
"type": tpl.type,
|
||||||
|
"text": content,
|
||||||
|
"parse_mode": tpl.parse_mode,
|
||||||
|
"keyboard_rows": keyboard_rows,
|
||||||
|
"_required": list(required_variables_of_template(tpl.content, tpl.keyboard_tpl or [])),
|
||||||
|
}
|
||||||
0
app/tasks/__init__.py
Normal file
0
app/tasks/__init__.py
Normal file
15
app/tasks/celery_app.py
Normal file
15
app/tasks/celery_app.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from celery import Celery
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"autopost",
|
||||||
|
broker=settings.celery_broker_url,
|
||||||
|
backend=settings.celery_result_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
timezone=settings.tz,
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
)
|
||||||
56
app/tasks/senders.py
Normal file
56
app/tasks/senders.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/{method}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_keyboard(keyboard: dict | None) -> dict | None:
|
||||||
|
if not keyboard:
|
||||||
|
return None
|
||||||
|
rows = []
|
||||||
|
for row in keyboard.get("rows", []):
|
||||||
|
rows.append([{"text": btn["text"], "url": btn["url"]} for btn in row])
|
||||||
|
return {"inline_keyboard": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_post_task(token: str, chat_id: int, payload: dict):
|
||||||
|
"""Отправка поста через Bot API. payload: {type, text, media_file_id, parse_mode, keyboard}
|
||||||
|
keyboard: {rows: [[{text, url}, ...], ...]}
|
||||||
|
"""
|
||||||
|
t = payload.get("type", "text")
|
||||||
|
reply_markup = _build_keyboard(payload.get("keyboard"))
|
||||||
|
|
||||||
|
method = "sendMessage"
|
||||||
|
data: dict = {"chat_id": chat_id}
|
||||||
|
|
||||||
|
if t == "text":
|
||||||
|
method = "sendMessage"
|
||||||
|
data.update({"text": payload.get("text", ""), "parse_mode": payload.get("parse_mode")})
|
||||||
|
elif t == "photo":
|
||||||
|
method = "sendPhoto"
|
||||||
|
data.update({"photo": payload.get("media_file_id"), "caption": payload.get("text", ""),
|
||||||
|
"parse_mode": payload.get("parse_mode")})
|
||||||
|
elif t == "video":
|
||||||
|
method = "sendVideo"
|
||||||
|
data.update({"video": payload.get("media_file_id"), "caption": payload.get("text", ""),
|
||||||
|
"parse_mode": payload.get("parse_mode")})
|
||||||
|
elif t == "animation":
|
||||||
|
method = "sendAnimation"
|
||||||
|
data.update({"animation": payload.get("media_file_id"), "caption": payload.get("text", ""),
|
||||||
|
"parse_mode": payload.get("parse_mode")})
|
||||||
|
|
||||||
|
if reply_markup:
|
||||||
|
data["reply_markup"] = reply_markup
|
||||||
|
|
||||||
|
url = TELEGRAM_API_BASE.format(token=token, method=method)
|
||||||
|
with httpx.Client(timeout=30) as client:
|
||||||
|
r = client.post(url, json=data)
|
||||||
|
try:
|
||||||
|
r.raise_for_status()
|
||||||
|
logger.info("Sent post to {} via {}", chat_id, method)
|
||||||
|
return r.json()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error("Telegram send failed: {} :: {}", e, r.text)
|
||||||
|
raise
|
||||||
84
docker-compose.yml
Normal file
84
docker-compose.yml
Normal file
@@ -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:
|
||||||
20
docker/Dockerfile.api
Normal file
20
docker/Dockerfile.api
Normal file
@@ -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"]
|
||||||
28
docker/Dockerfile.base
Normal file
28
docker/Dockerfile.base
Normal file
@@ -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=""
|
||||||
19
docker/Dockerfile.beat
Normal file
19
docker/Dockerfile.beat
Normal file
@@ -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"]
|
||||||
19
docker/Dockerfile.bot
Normal file
19
docker/Dockerfile.bot
Normal file
@@ -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"]
|
||||||
18
docker/Dockerfile.worker
Normal file
18
docker/Dockerfile.worker
Normal file
@@ -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"]
|
||||||
20
docker/entrypoint.sh
Executable file
20
docker/entrypoint.sh
Executable file
@@ -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 "$@"
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
87
migrations/env.py
Normal file
87
migrations/env.py
Normal file
@@ -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()
|
||||||
28
migrations/script.py.mako
Normal file
28
migrations/script.py.mako
Normal file
@@ -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"}
|
||||||
20
pyproject.toml
Normal file
20
pyproject.toml
Normal file
@@ -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
|
||||||
16
requirements.txt
Normal file
16
requirements.txt
Normal file
@@ -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
|
||||||
10
test_db.py
Normal file
10
test_db.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user