diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..fedf9b4 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/devcontainers/python:3-3.12-trixie + +ENV PYTHONUNBUFFERED 1 + +# [Optional] If your requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + + + diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..e9f4124 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/cirolosapio/devcontainers-features/alpine-bash:0": { + "version": "0.0.3", + "resolved": "ghcr.io/cirolosapio/devcontainers-features/alpine-bash@sha256:36cab39fa6859980ba9ac31e52737f855612bb18707222c92fef1441b29fd634", + "integrity": "sha256:36cab39fa6859980ba9ac31e52737f855612bb18707222c92fef1441b29fd634" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a5df0d1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/postgres +{ + "name": "Python 3 & PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "features": { + // "ghcr.io/cirolosapio/devcontainers-features/alpine-bash:0": {} + }, + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // This can be used to network with other containers or the host. + // "forwardPorts": [5000, 5432], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "pip install --user -r requirements.txt", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + "remoteUser": "vscode" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..525b477 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + image: postgres:latest + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + + # Add "forwardPorts": [5432] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +volumes: + postgres-data: diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4fe07c5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json,toml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8bf650b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Normalize line endings: LF in the repository and in the working tree. +# Prevents editors on Windows hosts from turning the whole tree into a CRLF diff. +* text=auto eol=lf + +# Binary assets — never touch these. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.woff binary +*.woff2 binary +*.ttf binary diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f33a02c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 0000000..4231e74 --- /dev/null +++ b/.github/workflows/dev.yml @@ -0,0 +1,32 @@ +name: Development +on: + push: + branches-ignore: + - stage + - master +jobs: + deploy: + name: Check code + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 poppler-utils + - name: Install requirements + run: | + pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Check pylint + run: | + pylint src + - name: Run tests + run: | + pytest diff --git a/.gitignore b/.gitignore index 6f6d037..8f60f99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .vscode/settings.json *__pycache__* +.pytest_cache/ diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..09090b8 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,13 @@ +[MAIN] +init-hook="from pylint.config import find_default_config_files; import os, sys; sys.path.append(os.path.dirname(next(find_default_config_files())))" +[SIMILARITIES] +ignore-imports=y +[MESSAGES CONTROL] +disable= + W, + too-many-function-args, + too-many-public-methods, + too-many-arguments, + too-few-public-methods, + not-callable, + duplicate-code diff --git a/README.md b/README.md index 23652a4..111a584 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,184 @@ # ChatIconRotBot -This is a bot designed to update chat avatars. +A Telegram bot for communities of trophy hunters. It collects platinum-trophy +screenshots posted by chat members, rotates them as the group chat avatar on a +schedule, and runs a monthly rating survey on the games that were shown. + +Built with [aiogram 3](https://docs.aiogram.dev/) (async), APScheduler, +SQLAlchemy 2.0 + PostgreSQL. All bot messages are in Russian. + +## How it works + +1. **Submit a trophy.** A member posts a photo captioned `@YourBotName Game Title` + (append `Xbox` for the Xbox platform, otherwise PlayStation is assumed). + The bot stores it in the chat's queue and replies with a random gaming quote + from [`src/data/quotations.yaml`](src/data/quotations.yaml). +2. **Rotate the avatar.** Every `delta` days a scheduled job takes the next entry + from the queue, sets it as the chat photo, posts and pins an announcement with + the hunter and the game, then removes the entry from the queue and moves the + history record forward. If the queue is empty, the chat's default avatar is + used instead. +3. **Rate the game.** The pinned announcement carries an inline *«Оценить»* + button that opens a private survey with the bot. Members score the game on + three axes from 1 to 10 — *Game*, *Picture*, *Difficulty*. The hunter who + submitted the trophy only scores *Difficulty*. +4. **Monthly results.** On the 1st of each month the bot aggregates the previous + month's scores, renders one table image per category, sends them as an album + and pins it. + +## Commands + +### Everyone + +| Command | Description | +| --- | --- | +| `/start` | Start the bot in the chat (registers the chat and its schedule) | +| `/help` | Show the command reference | +| `/show_queue` | Show the queue of pending trophies as a table image | +| `/delete_game` | Delete your most recently submitted trophy | +| `/history [@username]` | List all trophies of yourself or another user | +| `/top [dd.mm.yyyy]` | Trophy leaderboard, all-time or since the given date | +| `/games_info` | Wikipedia links for the games currently in the queue | + +### Chat owner / bot admins + +| Command | Description | +| --- | --- | +| `/show_settings` | Show the next rotation date, the interval and the default avatar | +| `/set_date DD/MM/YYYY HH:MM` | Set the next avatar rotation (UTC) | +| `/set_delta N` | Set the interval between rotations, in days | + +Posting a photo captioned `@YourBotName *Default*` as a user with +*change group info* rights sets the chat's fallback avatar, used whenever the +queue runs dry. + +## Requirements + +- Python 3.12 +- PostgreSQL +- System packages for table rendering: `poppler-utils`, `pango`, and a font with + Cyrillic coverage (the Docker image installs `font-noto`) + +## Configuration + +The bot is configured entirely through environment variables: + +| Variable | Description | +| --- | --- | +| `TOKEN` | Telegram Bot API token from [@BotFather](https://t.me/BotFather) | +| `DATABASE_URL` | Async SQLAlchemy DSN, e.g. `postgresql+asyncpg://user:pass@host:5432/db` | + +Bot administrator IDs are currently hardcoded in +[`src/bot/settings.py`](src/bot/settings.py) (`admin_ids`) — change them there. +Admins receive startup/shutdown notices and every unhandled error traceback. + +The bot must be an administrator in the group with permission to change chat +info and pin messages. ## Installation 1. Clone the repository: + + ```bash + git clone https://github.com/NickLyrick/ChatIconRotBot.git + cd ChatIconRotBot + ``` + +2. Create a virtual environment and install the dependencies: + + ```bash + python -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` + +3. Install the native dependencies (Debian/Ubuntu): + + ```bash + sudo apt-get install poppler-utils libpango-1.0-0 fonts-noto + ``` + +4. Provide the environment variables and run: + ```bash - git clone https://github.com/your-username/ChatIconRotBot.git + export TOKEN="123456:ABC-DEF..." + export DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/postgres" + python main.py + ``` + +> **Note:** the database schema is not created automatically. Create the tables +> described in [`src/database/schemas.py`](src/database/schemas.py) (`chats`, +> `platinum`, `history`, `scores`, `surveys`) before the first run. + +### Docker + +```bash +docker build -t chaticonrotbot . +docker run --rm -e TOKEN=... -e DATABASE_URL=... chaticonrotbot python main.py +``` + +### Dev container + +The repository ships a VS Code dev container +([`.devcontainer/`](.devcontainer/)) with Python 3.12 and a PostgreSQL service, +so you can open the project in a container and get a local database for free. + +## Project layout + +``` +main.py entry point: starts long polling +src/bot/ bot instance, settings, command descriptions +src/dispatcher/ dispatcher, router and middleware registration +src/handlers/ message and callback handlers + basic_handlers.py /start, /help, global error handler + records_handlers.py trophy submission and deletion + schedule_handlers.py /set_date, /set_delta, /show_settings + tables_handlers.py /show_queue, /top, /history + game_score_handlers.py inline survey flow + wiki_handlers.py /games_info +src/keyboards/inline/ survey keyboards and callback data +src/filters/ caption format, chat type and permission filters +src/middleware/ inject the DB request object and the scheduler +src/scheduler/ APScheduler wrapper and jobs + jobs.py change_avatar, finish_survey, check_db_connection +src/database/ SQLAlchemy models and all queries +src/utility/ PlatinumRecord model, HTML → PNG table renderer +src/data/quotations.yaml random quotes posted on submission +``` + +Table images are produced by rendering a pandas DataFrame to HTML, converting it +to PDF with WeasyPrint, rasterising it with pdf2image, and trimming the result +with Pillow — see [`src/utility/tools.py`](src/utility/tools.py). + +## Database schema + +| Table | Purpose | +| --- | --- | +| `chats` | Registered chats: next rotation date and interval in days | +| `platinum` | Pending queue of submitted trophies (hunter, game, platform, photo) | +| `history` | Archive of shown trophies, including the date the avatar was set | +| `scores` | Individual survey votes per trophy and user | +| `surveys` | Aggregated monthly results per trophy | + +## Deployment + +Deployment targets [Fly.io](https://fly.io) via GitHub Actions: + +- **`master`** → deploys the `platinum` app ([`.github/workflows/fly.yml`](.github/workflows/fly.yml)) +- **`stage`** → deploys the `platinum-dev` app ([`.github/workflows/stage.yml`](.github/workflows/stage.yml)) +- **any other branch** → runs `pylint src` only ([`.github/workflows/dev.yml`](.github/workflows/dev.yml)) + +Deploys require the `FLY_API_TOKEN` repository secret. `TOKEN` and +`DATABASE_URL` are expected to be set as Fly secrets. A `Procfile` is included +for Heroku-style platforms. + +## Development + +Lint the codebase the same way CI does: + +```bash +pip install pylint +pylint src +``` + +Rules live in [`.pylintrc`](.pylintrc). diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..8b364a2 --- /dev/null +++ b/conftest.py @@ -0,0 +1,17 @@ +"""Shared pytest configuration. + +``src.bot.settings`` reads its configuration from the environment at import +time and aiogram validates the token eagerly, so placeholders have to be in +place before anything under ``src`` is imported. Being at the repository root, +this module is loaded before any test module, which is early enough. + +The root itself reaches ``sys.path`` through ``pythonpath`` in pytest.ini. +""" + +import os + +# A syntactically valid but fake token — aiogram rejects malformed ones. +os.environ.setdefault("TOKEN", "123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw") +os.environ.setdefault( + "DATABASE_URL", "postgresql+asyncpg://user:password@localhost:5432/test" +) diff --git a/fly.toml b/fly.toml index e62a9b7..7c0d692 100644 --- a/fly.toml +++ b/fly.toml @@ -1,34 +1,21 @@ -# fly.toml file generated for platinum on 2022-12-13T14:19:08+05:00 - app = "platinum" -kill_signal = "SIGINT" -kill_timeout = 5 +primary_region = "ams" -[env] +# main.py catches KeyboardInterrupt, so SIGINT is what triggers the graceful +# shutdown: closing the bot session, the scheduler and the database pool, and +# notifying the admins over the network. Five seconds was not enough room. +kill_signal = "SIGINT" +kill_timeout = "30s" -[experimental] - allowed_public_ports = [] - auto_rollback = true - cmd = [] - entrypoint = [] - exec = [] +# No [[services]] or [http_service] on purpose. This is a Telegram long polling +# bot: it only opens outbound connections and never listens on a port. Declaring +# a service hands the machine to the fly proxy, which cordons and stops it once +# no traffic arrives on the declared port — that is what took the bot down. [processes] worker = "python main.py" -[[services]] +[[vm]] + size = "shared-cpu-1x" + memory = "768mb" processes = ["worker"] - protocol = "tcp" - [services.concurrency] - hard_limit = 25 - soft_limit = 20 - type = "connections" - - [[services.ports]] - force_https = true - handlers = ["http"] - port = 80 - - [[services.ports]] - handlers = ["tls", "http"] - port = 443 diff --git a/main.py b/main.py index e898aa8..e9981da 100644 --- a/main.py +++ b/main.py @@ -18,13 +18,14 @@ async def main() -> None: logging.basicConfig(level=logging.INFO) logging.info("Bot started") - await dispatcher.start_polling(bot) + try: + await dispatcher.start_polling(bot) + finally: + await bot.session.close() if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - asyncio.run(bot.session.close()) - finally: - asyncio.run(bot.session.close()) + pass diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..1d18969 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = tests +asyncio_mode = auto +# The repository root carries an __init__.py, so pytest treats it as a package +# and puts its *parent* on sys.path. Add the root explicitly, otherwise +# `import src` only resolves under `python -m pytest`, which injects the cwd. +pythonpath = . diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..eaae4dd --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,7 @@ +-r requirements.txt + +# Pinned exactly: a compatible-release range let CI resolve a different pylint +# than the one used locally, and the two disagree about which checks exist. +pylint==4.0.6 +pytest==9.1.1 +pytest-asyncio==1.4.0 diff --git a/requirements.txt b/requirements.txt index 78c6bc6..9531873 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ -aiogram==3.5.0 -APScheduler==3.10.4 -pytz~=2024.1 -wikipedia~=1.4.0 -pillow~=10.2.0 -weasyprint~=62.0 -typing~=3.7.4.3 -pandas~=2.2.0 -pdf2image~=1.17.0 -pyYAML==6.0.1 -SQLAlchemy==2.0.29 -asyncpg==0.29.0 \ No newline at end of file +aiogram==3.30.0 +APScheduler==3.11.3 +pytz==2026.3.post1 +wikipedia==1.4.0 +pillow==12.3.0 +weasyprint==69.0 +pandas==3.0.5 +python-dateutil==2.9.0.post0 +pdf2image==1.17.0 +pyYAML==6.0.3 +SQLAlchemy==2.0.51 +asyncpg==0.31.0 diff --git a/src/bot/commands.py b/src/bot/commands.py index 77443f6..1f63961 100644 --- a/src/bot/commands.py +++ b/src/bot/commands.py @@ -4,8 +4,6 @@ from aiogram.types import BotCommand from aiogram.utils import formatting -# TODO: Implement user_chat_commands - group_chat_commands = [ BotCommand(command="start", description="Запуск бота в чате"), BotCommand(command="help", description="Вывод справочной информации"), @@ -40,13 +38,13 @@ ), ] -chat_help_text = formatting.Bold("У бота есть следующие команды:").as_html() + "\n" +CHAT_HELP_TEXT = formatting.Bold("У бота есть следующие команды:").as_html() + "\n" for command in group_chat_commands: - chat_help_text += f"\n/{command.command} - {command.description}" + CHAT_HELP_TEXT += f"\n/{command.command} - {command.description}" -admin_help_text = ( +ADMIN_HELP_TEXT = ( formatting.Bold("Для администраторов бота доступны следующие команды:").as_html() + "\n" ) for command in bot_admin_commands: - admin_help_text += f"\n/{command.command} - {command.description}" + ADMIN_HELP_TEXT += f"\n/{command.command} - {command.description}" diff --git a/src/bot/instance.py b/src/bot/instance.py index 1ef0510..05455e6 100644 --- a/src/bot/instance.py +++ b/src/bot/instance.py @@ -1,8 +1,12 @@ """This module contains the bot instance.""" from aiogram import Bot +from aiogram.client.default import DefaultBotProperties from aiogram.enums.parse_mode import ParseMode from src.bot.settings import settings -bot = Bot(token=settings.bot.bot_token, parse_mode=ParseMode.HTML) +bot = Bot( + token=settings.bot.bot_token, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), +) diff --git a/src/bot/settings.py b/src/bot/settings.py index 785cb27..7d125d6 100644 --- a/src/bot/settings.py +++ b/src/bot/settings.py @@ -38,8 +38,8 @@ class Settings: # TODO: add admin_ids to environment variables admin_ids=[392087623], welcome_message="Я жажду платин!", - chat_help_message=commands.chat_help_text, - bot_admin_help_message=commands.admin_help_text, + chat_help_message=commands.CHAT_HELP_TEXT, + bot_admin_help_message=commands.ADMIN_HELP_TEXT, ), db=DB(database_url=environ["DATABASE_URL"]), ) diff --git a/src/database/connect.py b/src/database/connect.py index adacd41..51de01f 100644 --- a/src/database/connect.py +++ b/src/database/connect.py @@ -3,12 +3,17 @@ import logging from datetime import datetime -from typing import List, Optional, Tuple +from typing import List, NamedTuple, Optional, Tuple from pytz import timezone as tz from sqlalchemy import between, bindparam, delete, desc, func, select, update from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) from src.bot.settings import settings from src.utility.platinum_record import PlatinumRecord @@ -16,20 +21,47 @@ from .schemas import Chat, History, Platinum, Scores, Surveys +class QueuedAvatar(NamedTuple): + """The next avatar to publish, together with its announcement text. + + Every field except ``text`` is None when the queue is empty and the chat + falls back to its default avatar. + """ + + record_id: Optional[int] + file_id: Optional[str] + hunter_id: Optional[int] + game: Optional[str] + platform: Optional[str] + text: str + + class Request: """This class is responsible for handling all the database queries.""" def __init__(self): """Initialize the Request class.""" - self.session: async_sessionmaker[AsyncSession] = None + self.engine: Optional[AsyncEngine] = None + self.session: Optional[async_sessionmaker[AsyncSession]] = None - async def create_connection(self): + async def create_connection(self) -> None: """This function is used to get the connection to the database.""" - engine = create_async_engine(settings.db.database_url) + new_engine = create_async_engine(settings.db.database_url, pool_pre_ping=True) + old_engine = self.engine + self.engine = new_engine + self.session = async_sessionmaker(new_engine, expire_on_commit=False) + if old_engine is not None: + await old_engine.dispose() + + async def close(self) -> None: + """Close the database connection pool.""" - self.session = async_sessionmaker(engine, expire_on_commit=False) + if self.engine is not None: + await self.engine.dispose() + self.engine = None + self.session = None async def check_db_connection(self) -> bool: """This method is used to check the connection to the database.""" @@ -37,13 +69,20 @@ async def check_db_connection(self) -> bool: try: async with self.session() as session: await session.execute(select(1)) + return True except Exception as e: logging.error(f"Has lost connection to database:\n {e}") logging.info("Try reconnect to database") try: await self.create_connection() - except Exception as e: - logging.exception(f"Connect to database failed:\n {e}") + async with self.session() as session: + await session.execute(select(1)) + return True + except Exception as reconnect_error: + logging.exception( + f"Connect to database failed:\n {reconnect_error}" + ) + return False async def get_chats(self) -> dict: """This method is used to get all the chats from the database.""" @@ -88,7 +127,9 @@ async def get_chat_settings(self, chat_id) -> list: .where(Platinum.game == "*Default*") ) chat = (await session.scalars(statement_chat)).one() - default_avatar_record = (await session.scalars(statement_default)).one() + default_avatar_record = ( + await session.scalars(statement_default) + ).one_or_none() photo_id = None if default_avatar_record is not None: @@ -125,7 +166,7 @@ async def get_queue(self, chat_id): return trophies.all() - async def get_avatar(self, chat_id) -> tuple[str, int, str, str, str]: + async def get_avatar(self, chat_id) -> QueuedAvatar: """This method is used to get the avatar file_id from the database.""" async with self.session() as session: @@ -160,6 +201,7 @@ async def get_avatar(self, chat_id) -> tuple[str, int, str, str, str]: hunter_id = None game = None platform = None + record_id = None else: record = records[0] @@ -173,13 +215,24 @@ async def get_avatar(self, chat_id) -> tuple[str, int, str, str, str]: hunter_id = record.user_id game = record.game platform = record.platform + record_id = record.id + + return QueuedAvatar( + record_id=record_id, + file_id=file_id, + hunter_id=hunter_id, + game=game, + platform=platform, + text=text, + ) - await session.delete(record) + async def delete_queue_record(self, record_id: int) -> None: + """Delete a queue item after its avatar was changed successfully.""" + async with self.session() as session: + await session.execute(delete(Platinum).where(Platinum.id == record_id)) await session.commit() - return file_id, hunter_id, game, platform, text - async def get_top(self, chat_id, date: datetime): """This method is used to get the top from the database.""" @@ -439,6 +492,7 @@ async def get_survey_results( async def add_survey_history(self, score_type: str, data: List[Tuple[int, float]]): """This method is used to add results surveys to surveys history""" + async with self.session() as session: statement = ( insert(Surveys) diff --git a/src/database/schemas.py b/src/database/schemas.py index d9e94de..33474ac 100644 --- a/src/database/schemas.py +++ b/src/database/schemas.py @@ -16,6 +16,8 @@ class Base(DeclarativeBase): + """This class is the represent declare base class for all tables""" + pass diff --git a/src/dispatcher/instance.py b/src/dispatcher/instance.py index 77087ce..6eb6c76 100644 --- a/src/dispatcher/instance.py +++ b/src/dispatcher/instance.py @@ -62,6 +62,8 @@ async def register_middlewares(dp: Dispatcher, bot: Bot) -> None: dp.update.middleware.register(db_middleware) dp.update.middleware.register(scheduler_middleware) + dp["request"] = request + dp["scheduler"] = scheduler @dispatcher.startup() @@ -74,3 +76,15 @@ async def on_startup(dispatcher: Dispatcher, bot: Bot) -> None: await register_routers(dp=dispatcher) logging.info("Routers registered") + + +@dispatcher.shutdown() +async def on_shutdown(dispatcher: Dispatcher) -> None: + """Release scheduler and database resources.""" + + scheduler = dispatcher.get("scheduler") + request = dispatcher.get("request") + if scheduler is not None: + await scheduler.shutdown() + if request is not None: + await request.close() diff --git a/src/filters/filters.py b/src/filters/filters.py index 4410f5b..c7bf53a 100644 --- a/src/filters/filters.py +++ b/src/filters/filters.py @@ -69,16 +69,17 @@ class CheckPermissions(BaseFilter): async def __call__(self, message: Message) -> bool: try: member = await message.chat.get_member(message.from_user.id) - if ( + is_chat_admin: bool = ( member.status == ChatMemberStatus.CREATOR or member.user.id in settings.bot.admin_ids - ): + ) + if is_chat_admin: return True - else: - await message.reply( - text="У вас нет прав для изменения настроек бота для данного чата" - ) - return False + + await message.reply( + text="У вас нет прав для изменения настроек бота для данного чата" + ) + return False except Exception as e: await message.answer(text=f"Ошибка: \n" f"{formatting.Pre(e)}.as_html()") return False diff --git a/src/handlers/basic_handlers.py b/src/handlers/basic_handlers.py index 94dfa27..cfc9053 100644 --- a/src/handlers/basic_handlers.py +++ b/src/handlers/basic_handlers.py @@ -1,6 +1,5 @@ """Module providing a basic bot functionality.""" -# TODO: Implement user chat commands from aiogram import Bot, Router, types from aiogram.enums import ChatMemberStatus from aiogram.filters import Command, CommandStart @@ -67,7 +66,7 @@ async def help_admin_command(message: types.Message) -> None: @basic_router.error() async def error_handler(event: error_event.ErrorEvent, bot: Bot) -> None: - """Handle errors.""" + """Handle errors in basic router.""" content = formatting.as_list( formatting.Text(f"Ошибка в {__name__}:"), diff --git a/src/handlers/game_score_handlers.py b/src/handlers/game_score_handlers.py index 0b3278f..c0a6ee8 100644 --- a/src/handlers/game_score_handlers.py +++ b/src/handlers/game_score_handlers.py @@ -1,8 +1,9 @@ +"""Module providing a handlers for game scoring surveys.""" + import re from aiogram import Bot, F, Router -from aiogram.types import BufferedInputFile, CallbackQuery -from aiogram.types.error_event import ErrorEvent +from aiogram.types import BufferedInputFile, CallbackQuery, error_event from aiogram.utils import formatting from src.bot.settings import settings @@ -173,8 +174,8 @@ async def process_result( @game_score_router.error() -async def error_handler(event: ErrorEvent, bot: Bot) -> None: - """Handle errors.""" +async def error_handler(event: error_event.ErrorEvent, bot: Bot) -> None: + """Handle errors in game score router.""" content = formatting.as_list( formatting.Text(f"Ошибка в {__name__}:"), diff --git a/src/handlers/records_handlers.py b/src/handlers/records_handlers.py index a27a621..9ff0b40 100644 --- a/src/handlers/records_handlers.py +++ b/src/handlers/records_handlers.py @@ -7,7 +7,7 @@ from aiogram.enums.chat_member_status import ChatMemberStatus from aiogram.exceptions import AiogramError from aiogram.filters import Command -from aiogram.types.error_event import ErrorEvent +from aiogram.types import error_event from aiogram.utils import formatting from src.bot.settings import settings @@ -35,7 +35,6 @@ async def check_permissions(message: types.Message) -> bool: ) -# TODO: Split on 2 functions. Process Default avatar separately. Use my_filter.check_permissions and remove 17-24 @records_router.message(F.photo, my_filters.platinum_check) async def add_record(message: types.Message, bot: Bot, request: Request) -> None: """Add record to the database.""" @@ -95,8 +94,8 @@ async def delete_game(message: types.Message, bot: Bot, request: Request): @records_router.error() -async def error_handler(event: ErrorEvent, bot: Bot) -> None: - """Handle errors.""" +async def error_handler(event: error_event.ErrorEvent, bot: Bot) -> None: + """Handle errors in records router.""" content = formatting.as_list( formatting.Text(f"Ошибка в {__name__}:"), diff --git a/src/handlers/schedule_handlers.py b/src/handlers/schedule_handlers.py index 7ce5786..aa3c93a 100644 --- a/src/handlers/schedule_handlers.py +++ b/src/handlers/schedule_handlers.py @@ -5,7 +5,7 @@ import pytz from aiogram import Bot, Router, types from aiogram.filters import Command, CommandObject, CommandStart -from aiogram.types.error_event import ErrorEvent +from aiogram.types import error_event from aiogram.utils import formatting from src.bot.settings import settings @@ -47,7 +47,19 @@ async def set_date( chat_id = message.chat.id arguments = command.args - date = datetime.strptime(arguments, "%d/%m/%Y %H:%M") + if arguments is None: + await message.reply(text="Вы не указали дату") + return + + if not arguments: + await message.reply("Укажите дату: /set_date ДД/ММ/ГГГГ ЧЧ:ММ") + return + + try: + date = datetime.strptime(arguments, "%d/%m/%Y %H:%M") + except ValueError: + await message.reply("Неверный формат даты. Пример: /set_date 31/12/2026 18:00") + return date = date.replace(tzinfo=pytz.utc) if date > datetime.now(timezone.utc): @@ -78,10 +90,12 @@ async def set_delta( scheduler: Scheduler, ): """Set the delta for the chat avatar change.""" - bot = await bot.get_me() - chat_id = message.chat.id - delta = int(command.args) + + try: + delta = int(command.args) if command.args is not None else 0 + except ValueError: + delta = 0 if delta > 0: await request.set_chat_delta(chat_id=chat_id, delta=delta) @@ -136,8 +150,8 @@ async def show_settings(message: types.Message, bot: Bot, request: Request): @schedule_router.error() -async def error_handler(event: ErrorEvent, bot: Bot) -> None: - """Handle errors.""" +async def error_handler(event: error_event.ErrorEvent, bot: Bot) -> None: + """Handle errors in schedule router.""" content = formatting.as_list( formatting.Text(f"Ошибка в {__name__}:"), diff --git a/src/handlers/tables_handlers.py b/src/handlers/tables_handlers.py index 1b0a7bc..2bc9054 100644 --- a/src/handlers/tables_handlers.py +++ b/src/handlers/tables_handlers.py @@ -5,7 +5,7 @@ from aiogram import Bot, Router, flags, types from aiogram.enums import ChatAction from aiogram.filters import Command, CommandObject -from aiogram.types.error_event import ErrorEvent +from aiogram.types import error_event from aiogram.utils import formatting from src.bot.settings import settings @@ -86,11 +86,11 @@ async def get_history(message: types.Message, command: CommandObject, request: R chat_id = message.chat.id arguments = command.args - username = message.from_user.username + username = None user_id = message.from_user.id if arguments is not None: - username = arguments.replace("@", "") + username = arguments.strip().lstrip("@") or None data = [] try: @@ -98,17 +98,19 @@ async def get_history(message: types.Message, command: CommandObject, request: R chat_id=chat_id, user_id=user_id, username=username ) except Exception as e: + display_name = ("@" + username) if username else "пользователя" await message.reply( - text=f"Не удалось получить историю от @{username}\n\n" + text=f"Не удалось получить историю {display_name}\n\n" f"Ошибка: \n" f"{formatting.Pre(e).as_html()}" ) if len(data) > 0: + display_name = ("@" + username) if username else message.from_user.full_name media = table( data, columns=["Game", "Date", "Platform"], - caption=f"Список всех трофеев @{username}", + caption=f"Список всех трофеев: {display_name}", ) if len(media) > 0: await message.reply_media_group(media=media) @@ -117,8 +119,8 @@ async def get_history(message: types.Message, command: CommandObject, request: R @table_router.error() -async def error_handler(event: ErrorEvent, bot: Bot) -> None: - """Handle errors.""" +async def error_handler(event: error_event.ErrorEvent, bot: Bot) -> None: + """Handle errors in tables router.""" content = formatting.as_list( formatting.Text(f"Ошибка в {__name__}:"), diff --git a/src/handlers/wiki_handlers.py b/src/handlers/wiki_handlers.py index bba1d7d..11488b9 100644 --- a/src/handlers/wiki_handlers.py +++ b/src/handlers/wiki_handlers.py @@ -3,6 +3,7 @@ import wikipedia from aiogram import Bot, Router, types from aiogram.filters import Command +from aiogram.types import error_event from aiogram.utils import formatting from wikipedia import exceptions as wiki_exceptions @@ -57,8 +58,8 @@ async def games_info(message: types.Message, request: Request): @wiki_router.error() -async def error_handler(event: types.error_event.ErrorEvent, bot: Bot) -> None: - """Handle errors.""" +async def error_handler(event: error_event.ErrorEvent, bot: Bot) -> None: + """Handle errors in wiki router.""" content = formatting.as_list( formatting.Text(f"Ошибка в {__name__}:"), diff --git a/src/scheduler/jobs.py b/src/scheduler/jobs.py index 2761d1a..6eba2fe 100644 --- a/src/scheduler/jobs.py +++ b/src/scheduler/jobs.py @@ -21,43 +21,48 @@ async def change_avatar(bot: Bot, request: Request, chat_id: int, where_run: dic try: logging.info(f"Changing avatar for chat {chat_id}") - file_id, hunter_id, game, platform, text = await request.get_avatar(chat_id) + queued = await request.get_avatar(chat_id) - if file_id is not None: - avatar = await bot.download(file=file_id) + if queued.file_id is not None: + avatar = await bot.download(file=queued.file_id) await bot.set_chat_photo( chat_id=chat_id, photo=BufferedInputFile(file=avatar.read(), filename="avatar.png"), ) - if hunter_id is not None and game is not None: + if queued.hunter_id is not None and queued.game is not None: history_id = await request.get_history_id( - chat_id=chat_id, user_id=hunter_id, game=game, platform=platform + chat_id=chat_id, + user_id=queued.hunter_id, + game=queued.game, + platform=queued.platform, ) await request.add_avatar_date(history_id=history_id, date=datetime.now(timezone.utc)) - callback_data = GameSurveyCallbackData( - hunter_id=hunter_id, history_id=history_id - ) - avatar.seek(0) sended_message = await bot.send_photo( photo=BufferedInputFile(file=avatar.read(), filename="game.png"), chat_id=chat_id, - caption=text, + caption=queued.text, reply_markup=build_start_survey_keyboard( - text="Оценить", callback_data=callback_data + text="Оценить", + callback_data=GameSurveyCallbackData( + hunter_id=queued.hunter_id, history_id=history_id + ) ), ) await bot.pin_chat_message(chat_id=chat_id, message_id=sended_message.message_id) else: - await bot.send_message(chat_id=chat_id, text=text) + await bot.send_message(chat_id=chat_id, text=queued.text) - t_delta = timedelta(days=where_run[chat_id]["delta"]) - date = where_run[chat_id]["date"] + t_delta + if queued.record_id is not None: + await request.delete_queue_record(queued.record_id) + + date = where_run[chat_id]["date"] + timedelta(days=where_run[chat_id]["delta"]) await request.set_chat_date(chat_id, date) + where_run[chat_id]["date"] = date except Exception as e: for admin_id in settings.bot.admin_ids: await bot.send_message( @@ -103,16 +108,22 @@ async def finish_survey(bot: Bot, request: Request, chat_id: int): if len(media) == 0: logging.warning( - f"{__name__}: The survey has not been conducted or has already been completed in chat {chat_id}" + ( + f"{__name__}: The survey has not been conducted " + f"or has already been completed in chat {chat_id}" + ) ) else: # Send tables with survey results - media[0].caption = f"Результаты опроса за {date_start.strftime("%m.%Y")}" + # aiogram models are frozen, so replace the entry instead of mutating it. + media[0] = media[0].model_copy( + update={"caption": f"Результаты опроса за {date_start.strftime('%m.%Y')}"} + ) survey_message = await bot.send_media_group(chat_id=chat_id, media=media) # Pin Survey Message await bot.pin_chat_message(chat_id=chat_id, message_id=survey_message[0].message_id) - + # Delete scores for calculated trophy_ids await request.delete_scores(trophy_ids=trophy_ids) except Exception as e: diff --git a/src/scheduler/scheduler.py b/src/scheduler/scheduler.py index 22c965a..872baea 100644 --- a/src/scheduler/scheduler.py +++ b/src/scheduler/scheduler.py @@ -10,13 +10,16 @@ from src.database import Request from src.scheduler.jobs import change_avatar, check_db_connection, finish_survey +# Fallback rotation interval, in days, for a chat whose interval is unknown. +DEFAULT_DELTA = 1 + class Scheduler: """Scheduler class.""" def __init__(self, scheduler: AsyncIOScheduler): self.scheduler: AsyncIOScheduler = scheduler - self.where_run: dict = dict() + self.where_run: dict = {} async def start(self, bot: Bot, request: Request) -> None: """Start the scheduler.""" @@ -61,6 +64,12 @@ async def start(self, bot: Bot, request: Request) -> None: args=[bot, request, chat_id], ) + async def shutdown(self) -> None: + """Stop all scheduled jobs.""" + + if self.scheduler.running: + self.scheduler.shutdown(wait=False) + async def add_change_avatar_job( self, bot: Bot, @@ -69,10 +78,24 @@ async def add_change_avatar_job( date: datetime = None, delta: int = None, ) -> None: - """Add a job to the scheduler.""" + """Add or reschedule the avatar rotation job of a chat. - job = self.scheduler.get_job(str(chat_id)) + Both ``date`` and ``delta`` are optional: /set_date only knows the date + and /set_delta only knows the interval. Whatever is missing is taken + from the state already stored for the chat, so neither the trigger nor + ``where_run`` can ever end up holding None. + """ + + known = self.where_run.get(chat_id, {}) + if date is None: + date = known.get("date") or datetime.now(timezone.utc) + if delta is None: + delta = known.get("delta") or DEFAULT_DELTA + + self.where_run[chat_id] = {"date": date, "delta": delta} + + job = self.scheduler.get_job(str(chat_id)) if job is None: self.scheduler.add_job( func=change_avatar, @@ -83,12 +106,5 @@ async def add_change_avatar_job( args=[bot, request, chat_id, self.where_run], ) else: - if date is None: - date = self.where_run[chat_id]["date"] - self.where_run[chat_id]["delta"] = delta - if delta is None: - delta = self.where_run[chat_id]["delta"] - self.where_run[chat_id]["date"] = date - job.reschedule(trigger="interval", days=delta, start_date=date) job.modify(args=[bot, request, chat_id, self.where_run]) diff --git a/src/utility/tools.py b/src/utility/tools.py index c2a954d..f8d6dc1 100644 --- a/src/utility/tools.py +++ b/src/utility/tools.py @@ -58,12 +58,13 @@ def table(data, columns, caption: str = None, name: str = None): trimmed.save(img, "PNG") img.seek(0) - page = types.InputMediaPhoto( - type="photo", media=types.BufferedInputFile(img.read(), filename=f"{i}.png") + # aiogram models are frozen, so the caption has to be set on creation. + media.append( + types.InputMediaPhoto( + type="photo", + media=types.BufferedInputFile(img.read(), filename=f"{i}.png"), + caption=caption if i == 0 else None, + ) ) - if i == 0: - page.caption = caption - - media.append(page) return media diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..add1151 --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,127 @@ +"""Tests for :class:`src.scheduler.scheduler.Scheduler` job management. + +The APScheduler instance is real but never started: ``add_job`` keeps the job +pending and ``get_job`` still finds it, which is enough to observe the trigger +that was built — and building the trigger is exactly where a missing interval +used to blow up. +""" + +from datetime import datetime, timedelta, timezone + +import pytest +import pytz +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from src.scheduler.scheduler import DEFAULT_DELTA, Scheduler + +CHAT_ID = -1001234567890 + +# change_avatar is called with exactly these four arguments; APScheduler +# validates the signature when the job is created, but never calls it here. +BOT = object() +REQUEST = object() + + +@pytest.fixture(name="scheduler") +def scheduler_fixture(): + """A Scheduler over a real, never started, AsyncIOScheduler.""" + + return Scheduler(AsyncIOScheduler(timezone=pytz.utc)) + + +def get_trigger(scheduler: Scheduler): + """Return the interval trigger of the chat's job.""" + + job = scheduler.scheduler.get_job(str(CHAT_ID)) + assert job is not None, "no job was scheduled for the chat" + return job.trigger + + +async def test_set_date_creates_missing_job(scheduler): + """A known chat without a job must not crash on /set_date. + + The job is missing whenever start() skipped the chat because the bot was + not a member at boot. /set_date passes no delta, and the interval used to + be handed to the trigger as None. + """ + + scheduler.where_run[CHAT_ID] = { + "date": datetime(2026, 1, 1, tzinfo=timezone.utc), + "delta": 7, + } + date = datetime(2026, 8, 1, 12, 0, tzinfo=timezone.utc) + + await scheduler.add_change_avatar_job(BOT, REQUEST, CHAT_ID, date=date) + + trigger = get_trigger(scheduler) + assert trigger.interval == timedelta(days=7), "stored interval was dropped" + assert trigger.start_date == date + assert scheduler.where_run[CHAT_ID] == {"date": date, "delta": 7} + + +async def test_set_delta_creates_missing_job(scheduler): + """The mirror case: /set_delta passes no date, so the stored one is used.""" + + date = datetime(2026, 1, 1, tzinfo=timezone.utc) + scheduler.where_run[CHAT_ID] = {"date": date, "delta": 7} + + await scheduler.add_change_avatar_job(BOT, REQUEST, CHAT_ID, delta=3) + + trigger = get_trigger(scheduler) + assert trigger.interval == timedelta(days=3) + assert trigger.start_date == date, "stored date was dropped" + assert scheduler.where_run[CHAT_ID] == {"date": date, "delta": 3} + + +async def test_unknown_chat_falls_back_to_default_delta(scheduler): + """With nothing stored for the chat, the interval falls back instead of None.""" + + date = datetime(2026, 8, 1, 12, 0, tzinfo=timezone.utc) + + await scheduler.add_change_avatar_job(BOT, REQUEST, CHAT_ID, date=date) + + assert get_trigger(scheduler).interval == timedelta(days=DEFAULT_DELTA) + assert scheduler.where_run[CHAT_ID] == {"date": date, "delta": DEFAULT_DELTA} + + +async def test_unknown_chat_falls_back_to_now(scheduler): + """With no date either, the rotation starts from now rather than from None.""" + + before = datetime.now(timezone.utc) + + await scheduler.add_change_avatar_job(BOT, REQUEST, CHAT_ID, delta=5) + + trigger = get_trigger(scheduler) + assert trigger.interval == timedelta(days=5) + assert before <= trigger.start_date <= datetime.now(timezone.utc) + + +async def test_where_run_never_holds_none(scheduler): + """change_avatar adds where_run['delta'] to where_run['date'] unguarded. + + Either of them being None turns every single rotation into a TypeError, so + no call may leave a partially filled entry behind. + """ + + await scheduler.add_change_avatar_job(BOT, REQUEST, CHAT_ID) + + entry = scheduler.where_run[CHAT_ID] + assert entry["date"] is not None + assert entry["delta"] is not None + # The arithmetic change_avatar performs after a successful rotation. + assert entry["date"] + timedelta(days=entry["delta"]) > entry["date"] + + +async def test_existing_job_is_rescheduled_not_duplicated(scheduler): + """A second call reschedules the same job and merges the new value in.""" + + date = datetime(2026, 1, 1, tzinfo=timezone.utc) + await scheduler.add_change_avatar_job(BOT, REQUEST, CHAT_ID, date=date, delta=7) + + await scheduler.add_change_avatar_job(BOT, REQUEST, CHAT_ID, delta=2) + + assert len(scheduler.scheduler.get_jobs()) == 1 + trigger = get_trigger(scheduler) + assert trigger.interval == timedelta(days=2) + assert trigger.start_date == date + assert scheduler.where_run[CHAT_ID] == {"date": date, "delta": 2}