Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,29 @@ jobs:
- name: Ruff format check
run: ruff format --check "$GITHUB_WORKSPACE/app" "$GITHUB_WORKSPACE/tests"

# Job id is the status-check context required by the "main protection"
# ruleset ("test"). A matrix would report it as "test (3.12)" and never
# satisfy that requirement, so keep it a single un-matrixed job.
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
python-version: "3.12"
- name: Install Poetry
run: python -m pip install --upgrade pip poetry
- name: Install bot dependencies (system env, mirrors Dockerfile)
working-directory: app/bot
# --only main,testing: the dev group (a non-optional group, so installed
# by default) pulls poetry-plugin-up -> an old poetry -> dulwich<0.22,
# which with virtualenvs.create false clobbers Poetry's own dulwich
# mid-install and breaks fetching the dialog-engine git dependency
# ("No module named dulwich.worktree"). Tests need pytest-cov, which now
# lives in the testing group.
run: |
poetry config virtualenvs.create false
poetry install --with testing,dev --no-root
poetry install --only main,testing --no-root
- name: Run tests
# Publisher microservice tests need each service's own deps (asyncssh,
# aioprometheus, ...) which aren't in the bot env — they run in the
Expand Down
43 changes: 43 additions & 0 deletions app/bot/filters/dialog_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""aiogram filters that route messages by the active dialog step.

These replace the aiogram ``StateFilter`` / ``StatesGroup`` membership checks.
Routing decisions are delegated to :mod:`dialog_engine`: a message reaches a
handler only when the user's :class:`~dialog_engine.DialogSession` is active and
sitting on the expected step.
"""

from __future__ import annotations

from aiogram.filters import BaseFilter
from aiogram.fsm.context import FSMContext
from aiogram.types import TelegramObject
from dialog_engine import DialogEngine

from forms.upload_file import upload_file_engine
from utils.dialog import load_session


class InDialog(BaseFilter):
"""Pass when the user has an active dialog session (on any step)."""

def __init__(self, engine: DialogEngine = upload_file_engine) -> None:
self.engine = engine

async def __call__(self, _event: TelegramObject, state: FSMContext) -> bool:
session = await load_session(state, self.engine)
return session is not None and session.is_active


class OnStep(BaseFilter):
"""Pass when the active dialog session is currently on ``step_id``."""

def __init__(self, step_id: str, engine: DialogEngine = upload_file_engine) -> None:
self.step_id = step_id
self.engine = engine

async def __call__(self, _event: TelegramObject, state: FSMContext) -> bool:
session = await load_session(state, self.engine)
if session is None or not session.is_active:
return False
current = self.engine.current_step(session)
return current is not None and current.id == self.step_id
49 changes: 44 additions & 5 deletions app/bot/forms/upload_file.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
from aiogram.fsm.state import State, StatesGroup
"""Upload-episode dialog schema, driven by :mod:`dialog_engine`.

This replaces the former aiogram ``UploadFile`` ``StatesGroup``. The flow has
three steps — pick the episode type, send the MP3, send the metadata template —
and the engine owns step order, validation and answer storage.

class UploadFile(StatesGroup):
type_episode = State()
mp3 = State()
template = State()
The aiogram handlers in :mod:`handlers.podcast_handler` still render the
per-language prompts and perform the Telegram-side side effects (download,
tagging, upload); they consult this engine only for navigation and to read the
collected answers.
"""

from dialog_engine import DialogEngine

# Step IDs are referenced by the handlers and the ``OnStep`` filter, so keep
# them as named constants to avoid stringly-typed drift.
TYPE_EPISODE = "type_episode"
MP3 = "mp3"
TEMPLATE = "template"

DIALOG_ID = "upload_file"

# Canonical episode kinds stored in ``session.answers[TYPE_EPISODE]``. The
# display text is rendered per-language by the handlers/keyboards, so the
# ``choices`` values here are just i18n attribute names for reference.
upload_file_engine = DialogEngine.from_list(
[
{
"id": TYPE_EPISODE,
"type": "choice",
"text": "ask_typeEpisode",
"choices": {"main": "main_episode", "aftershow": "episode_aftershow"},
},
{
"id": MP3,
"type": "file",
"text": "ask_mp3",
},
{
"id": TEMPLATE,
"type": "text",
"text": "ask_template",
},
],
dialog_id=DIALOG_ID,
)
35 changes: 22 additions & 13 deletions app/bot/handlers/podcast_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pathlib import Path

from aiogram import Bot, F, Router
from aiogram.filters import CommandStart, StateFilter
from aiogram.filters import CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile, Message, ReplyKeyboardRemove
from loguru import logger
Expand All @@ -21,9 +21,11 @@
LOCAL,
PODCAST_PATH,
)
from filters.dialog_filters import InDialog, OnStep
from filters.dispatcher_filters import ContextButton, IsAdmin, IsPrivate
from forms.upload_file import UploadFile
from forms.upload_file import MP3, TEMPLATE, TYPE_EPISODE, upload_file_engine
from services import context, keyboards
from utils.dialog import load_session, save_session, start_dialog
from utils.FTP_methods import get_last_post_ID
from utils.MP3_methods import audio_tag
from utils.podcast_methods import generate_file_name
Expand Down Expand Up @@ -66,11 +68,11 @@ async def start(msg: Message, state: FSMContext, language: str):
context[language].ask_typeEpisode,
reply_markup=keyboards["podcast_handler"][language].type_episode,
)
await state.set_state(UploadFile.type_episode)
await start_dialog(state, upload_file_engine)


@logger.catch
@router.message(F.text, ContextButton("cancel"), StateFilter(UploadFile))
@router.message(F.text, ContextButton("cancel"), InDialog())
async def cancel(msg: Message, state: FSMContext, language: str, username: str):
"""Отмена загрузки MP3."""
logger.debug(f"[{username}]: Отмена загрузки MP3")
Expand All @@ -85,12 +87,14 @@ async def cancel(msg: Message, state: FSMContext, language: str, username: str):
@router.message(
F.text,
ContextButton(["main_episode", "episode_aftershow"]),
UploadFile.type_episode,
OnStep(TYPE_EPISODE),
)
async def get_type(msg: Message, state: FSMContext, language: str, username: str):
"""Выбор типа эпизода."""
type_episode = "main" if msg.text == context[language].main_episode else "aftershow"
await state.update_data(type_episode=type_episode)
session = await load_session(state, upload_file_engine)
await upload_file_engine.async_submit(session, type_episode)
await save_session(state, session)
logger.debug(f"[{username}]: Выбран тип эпизода: {type_episode}")

type_episode_text = ( # noqa: F841 — used by context format_map
Expand All @@ -100,13 +104,13 @@ async def get_type(msg: Message, state: FSMContext, language: str, username: str
context[language].ask_mp3,
reply_markup=keyboards["podcast_handler"][language].cancel,
)
await state.set_state(UploadFile.mp3)


@logger.catch
@router.message(UploadFile.mp3, F.audio)
@router.message(OnStep(MP3), F.audio)
async def get_MP3(msg: Message, state: FSMContext, bot: Bot, language: str, username: str):
"""Обработка загрузки MP3."""
session = await load_session(state, upload_file_engine)
await clear_old_mp3_files()

logger.debug(f"[{username}]: Загружает MP3...")
Expand Down Expand Up @@ -153,24 +157,26 @@ async def progress_callback(bytes_uploaded: int):
await state.clear()
return

episode_data = await state.get_data()
type_episode = episode_data["type_episode"]
type_episode = session.answers[TYPE_EPISODE]

numberLastEpisode = str(int(await get_last_post_ID(type_episode, FTP_SERVER, FTP_LOGIN, FTP_PASSWORD)) + 1)

await upload_file_engine.async_submit(session, msg.audio.file_id)
await save_session(state, session)

await download_msg.edit_text(context[language].downloaded)
await msg.answer(
context[language].ask_template[type_episode].replace("600", numberLastEpisode),
reply_markup=keyboards["podcast_handler"][language].cancel,
)
await state.set_state(UploadFile.template)


@logger.catch
@router.message(F.text, UploadFile.template, flags={"long_operation": "upload_audio"})
@router.message(F.text, OnStep(TEMPLATE), flags={"long_operation": "upload_audio"})
async def set_template(msg: Message, state: FSMContext, language: str, username: str):
"""Обработка шаблона для MP3-тегов."""
type_episode = (await state.get_data())["type_episode"]
session = await load_session(state, upload_file_engine)
type_episode = session.answers[TYPE_EPISODE]
logger.debug(f"[{username}]: Выбранный тип эпизода: {type_episode}")

info = validate_template(msg.text)
Expand Down Expand Up @@ -220,4 +226,7 @@ async def progress_callback(bytes_uploaded: int):

await tmp.delete()
logger.debug(f"[{username}]: MP3 загружен и отправлен в чат")

# Final step — mark the dialog complete, then drop the session from storage.
await upload_file_engine.async_submit(session, msg.text)
await state.clear()
27 changes: 24 additions & 3 deletions app/bot/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion app/bot/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,17 @@ pydantic = "^2.12.3"
pydantic-settings = "^2.9.1"
pre-commit = "^4.5.1"
pytz = "^2026.1.post1"
# Multi-step dialog engine. Pulled from GitHub until it lands on PyPI,
# then this switches to a plain version constraint.
dialog-engine = { git = "https://github.com/k0te1ch/DialogEngine.git" }


[tool.poetry.group.testing.dependencies]
pytest = "^8.3.2"
pytest-asyncio = "^0.24.0"
aiogram-testing = "^1.1.0"
responses = "^0.23.1"
pytest-cov = "^5.0.0"


[tool.poetry.group.e2e]
Expand All @@ -52,7 +56,6 @@ tgtest = {git = "https://github.com/k0te1ch/tgtest"}
[tool.poetry.group.dev.dependencies]
ruff = "^0.11"
poetry-plugin-up = "^0.7.1"
pytest-cov = "^5.0.0"
pytest = "^8.3.2"


Expand Down
37 changes: 37 additions & 0 deletions app/bot/utils/dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Glue between :mod:`dialog_engine` and aiogram's FSM storage.

``dialog_engine`` is framework-agnostic: the schema lives in a stateless
:class:`~dialog_engine.DialogEngine` and all run-time state lives in a
serialisable :class:`~dialog_engine.DialogSession`. These helpers persist that
session inside aiogram's :class:`~aiogram.fsm.context.FSMContext` (backed by
Redis in production), so a running dialog survives restarts just like the old
FSM states did.
"""

from __future__ import annotations

from aiogram.fsm.context import FSMContext
from dialog_engine import DialogEngine, DialogSession

# Key under which the serialised session lives in the FSM data dict.
SESSION_KEY = "dialog_session"


async def load_session(state: FSMContext, engine: DialogEngine) -> DialogSession | None:
"""Return the dialog session stored in *state*, or ``None`` if absent."""
raw = (await state.get_data()).get(SESSION_KEY)
if not raw:
return None
return engine.restore_session(raw)


async def save_session(state: FSMContext, session: DialogSession) -> None:
"""Serialise *session* back into the FSM data."""
await state.update_data({SESSION_KEY: session.to_dict()})


async def start_dialog(state: FSMContext, engine: DialogEngine) -> DialogSession:
"""Begin a fresh dialog, replacing any session already in *state*."""
session = engine.create_session()
await save_session(state, session)
return session
Loading
Loading