From 469bb9a04d8c89cbaca66128b726ebddef1f82c2 Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sat, 1 Jun 2024 21:43:14 +0400 Subject: [PATCH 01/15] Added actions for like dev branches (#57) * Added actions for like dev branches * Added config for pylint * Set RC for pylint * Remove extra options for pylint * Fixed pylint RC file * Set checkout version to 4 * Rework run linters * Run pylint without set rcfile * ls folder * Install requirements * Disabled several options * Enable cache for pip * linter fixes * linter fixes 2 * disable not-callable * Add blank line * fix survey string * Add space * return error handlers * fix None args in set_date and set_delta * disable duplicate-code --------- Co-authored-by: Artem Kopytin Co-authored-by: Aleksandr Kirilkin --- .github/workflows/dev.yml | 27 +++++++++++++++++++++++++++ .pylintrc | 13 +++++++++++++ src/bot/commands.py | 10 ++++------ src/bot/settings.py | 4 ++-- src/database/connect.py | 1 + src/database/schemas.py | 2 ++ src/filters/filters.py | 15 ++++++++------- src/handlers/basic_handlers.py | 3 +-- src/handlers/game_score_handlers.py | 9 +++++---- src/handlers/records_handlers.py | 7 +++---- src/handlers/schedule_handlers.py | 15 ++++++++++++--- src/handlers/tables_handlers.py | 6 +++--- src/handlers/wiki_handlers.py | 5 +++-- src/scheduler/jobs.py | 19 ++++++++++--------- src/scheduler/scheduler.py | 2 +- 15 files changed, 95 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/dev.yml create mode 100644 .pylintrc diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 0000000..ac9bd1d --- /dev/null +++ b/.github/workflows/dev.yml @@ -0,0 +1,27 @@ +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 requirements + run: | + pip install -r requirements.txt + - name: Install linters + run: | + pip install --upgrade pip + pip install pylint + - name: Check pylint + run: | + pylint src 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/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/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..54639c9 100644 --- a/src/database/connect.py +++ b/src/database/connect.py @@ -439,6 +439,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/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..52aa73b 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,6 +47,10 @@ async def set_date( chat_id = message.chat.id arguments = command.args + if arguments is None: + await message.reply(text="Вы не указали дату") + return + date = datetime.strptime(arguments, "%d/%m/%Y %H:%M") date = date.replace(tzinfo=pytz.utc) @@ -81,6 +85,11 @@ async def set_delta( bot = await bot.get_me() chat_id = message.chat.id + + if command.args is None: + await message.reply(text="Вы не указали временной промежуток") + return + delta = int(command.args) if delta > 0: @@ -136,8 +145,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..7690fd3 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 @@ -117,8 +117,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..f57cc59 100644 --- a/src/scheduler/jobs.py +++ b/src/scheduler/jobs.py @@ -37,10 +37,6 @@ async def change_avatar(bot: Bot, request: Request, chat_id: int, where_run: dic 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( @@ -48,15 +44,17 @@ async def change_avatar(bot: Bot, request: Request, chat_id: int, where_run: dic chat_id=chat_id, caption=text, reply_markup=build_start_survey_keyboard( - text="Оценить", callback_data=callback_data + text="Оценить", + callback_data=GameSurveyCallbackData( + hunter_id=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) - t_delta = timedelta(days=where_run[chat_id]["delta"]) - date = where_run[chat_id]["date"] + t_delta + date = where_run[chat_id]["date"] + timedelta(days=where_run[chat_id]["delta"]) await request.set_chat_date(chat_id, date) except Exception as e: for admin_id in settings.bot.admin_ids: @@ -103,7 +101,10 @@ 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 @@ -112,7 +113,7 @@ async def finish_survey(bot: Bot, request: Request, chat_id: int): # 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..f4630cd 100644 --- a/src/scheduler/scheduler.py +++ b/src/scheduler/scheduler.py @@ -16,7 +16,7 @@ class Scheduler: 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.""" From 11afe96561c56062c910ab2ec6862d1f274cce4e Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 12:45:16 +0000 Subject: [PATCH 02/15] Enforce LF line endings via .gitattributes and .editorconfig Editors on Windows hosts were rewriting the whole tree to CRLF, which turned 32 unrelated files into noise in every diff. Normalize on LF at the git level and mirror it in the editor config. --- .editorconfig | 18 ++++++++++++++++++ .gitattributes | 15 +++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes 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 From e181b3e9bfab8b05ce307535a3cd934b19b389aa Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 12:45:24 +0000 Subject: [PATCH 03/15] Fill in README with full English documentation Describe the trophy submission, avatar rotation and monthly survey flow, the command reference, configuration, installation, project layout, database schema and the Fly.io deployment pipeline. --- README.md | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 177 insertions(+), 2 deletions(-) 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). From afd4267100afea1fb9decfe7cb9eefe91fa17a03 Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:04:32 +0000 Subject: [PATCH 04/15] Add dev container and Dependabot configuration A Python 3.12 container with a PostgreSQL service, so the bot can be run against a local database without installing anything on the host. --- .devcontainer/Dockerfile | 15 ++++++++++++ .devcontainer/devcontainer-lock.json | 9 +++++++ .devcontainer/devcontainer.json | 27 +++++++++++++++++++++ .devcontainer/docker-compose.yml | 35 ++++++++++++++++++++++++++++ .github/dependabot.yml | 12 ++++++++++ 5 files changed, 98 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100644 .github/dependabot.yml 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/.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 From 133e98715fe799482905dc7abd356e52b0a2354b Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:04:32 +0000 Subject: [PATCH 05/15] Fix /show_settings crash in chats without a default avatar get_chat_settings() asked for exactly one *Default* record, so the query raised NoResultFound in every chat where no default avatar had been set. --- src/database/connect.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/database/connect.py b/src/database/connect.py index 54639c9..a28ecae 100644 --- a/src/database/connect.py +++ b/src/database/connect.py @@ -88,7 +88,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: From 891675a5ebd4c1696fbaadcd838d8d1b07b89172 Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:04:55 +0000 Subject: [PATCH 06/15] Shut down the scheduler and the database pool cleanly The bot session used to be closed twice through asyncio.run() on an already closed loop, and neither the scheduler nor the connection pool was released at all. Close the session in a finally block, release both resources from a dispatcher shutdown hook, and let Request own its engine so it can be disposed and rebuilt. Reconnection is now verified instead of assumed: check_db_connection() re-runs the probe query after reconnecting and reports the outcome, and the engine is created with pool_pre_ping so stale connections are detected before a query uses them. --- main.py | 9 +++++---- src/database/connect.py | 38 +++++++++++++++++++++++++++++++------- src/dispatcher/instance.py | 14 ++++++++++++++ src/scheduler/scheduler.py | 6 ++++++ 4 files changed, 56 insertions(+), 11 deletions(-) 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/src/database/connect.py b/src/database/connect.py index a28ecae..7cdcfec 100644 --- a/src/database/connect.py +++ b/src/database/connect.py @@ -8,7 +8,12 @@ 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 @@ -22,14 +27,26 @@ class Request: 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() - self.session = async_sessionmaker(engine, expire_on_commit=False) + async def close(self) -> None: + """Close the database connection pool.""" + + 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 +54,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.""" 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/scheduler/scheduler.py b/src/scheduler/scheduler.py index f4630cd..bb66351 100644 --- a/src/scheduler/scheduler.py +++ b/src/scheduler/scheduler.py @@ -61,6 +61,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, From bef5e56f499050e8bceacecad2efa85c02657322 Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:05:10 +0000 Subject: [PATCH 07/15] Delete a queued trophy only after its avatar was published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_avatar() removed the record as part of reading it, so a failure in set_chat_photo or send_photo — losing admin rights, for instance — threw the trophy away without ever showing it. Return the record id instead and delete it from change_avatar once the rotation has actually succeeded; a failed rotation now simply retries on the next tick. The result is returned as a QueuedAvatar named tuple: six positional values, three of them strings in a row, were easy to unpack wrongly. --- src/database/connect.py | 37 ++++++++++++++++++++++++++++++++----- src/scheduler/jobs.py | 22 ++++++++++++++-------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/database/connect.py b/src/database/connect.py index 7cdcfec..51de01f 100644 --- a/src/database/connect.py +++ b/src/database/connect.py @@ -3,7 +3,7 @@ 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 @@ -21,6 +21,21 @@ 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.""" @@ -151,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: @@ -186,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] @@ -199,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.""" diff --git a/src/scheduler/jobs.py b/src/scheduler/jobs.py index f57cc59..8778ba8 100644 --- a/src/scheduler/jobs.py +++ b/src/scheduler/jobs.py @@ -21,18 +21,21 @@ 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)) @@ -42,17 +45,20 @@ async def change_avatar(bot: Bot, request: Request, chat_id: int, where_run: dic 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=GameSurveyCallbackData( - hunter_id=hunter_id, history_id=history_id + 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) + + 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) From 0f7510d7cac3ed1f49b45c2d69c44ac64e33fa63 Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:05:10 +0000 Subject: [PATCH 08/15] Advance the in-memory rotation date after each avatar change change_avatar() persisted the next date but never updated where_run, so every rotation kept computing the interval from the same original date. --- src/scheduler/jobs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/scheduler/jobs.py b/src/scheduler/jobs.py index 8778ba8..841aea7 100644 --- a/src/scheduler/jobs.py +++ b/src/scheduler/jobs.py @@ -62,6 +62,7 @@ async def change_avatar(bot: Bot, request: Request, chat_id: int, where_run: dic 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( From 00cf785404e12582c75cabb2d31397cedba55249 Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:05:10 +0000 Subject: [PATCH 09/15] Validate the arguments of /set_date, /set_delta and /history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A malformed date or a non-numeric interval raised straight out of the handler; both now answer with the expected format. /history no longer falls back to the caller's username, which returned nothing for users who have none — it looks the caller up by user id instead. --- src/handlers/schedule_handlers.py | 21 +++++++++++++-------- src/handlers/tables_handlers.py | 10 ++++++---- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/handlers/schedule_handlers.py b/src/handlers/schedule_handlers.py index 52aa73b..aa3c93a 100644 --- a/src/handlers/schedule_handlers.py +++ b/src/handlers/schedule_handlers.py @@ -51,7 +51,15 @@ async def set_date( await message.reply(text="Вы не указали дату") return - date = datetime.strptime(arguments, "%d/%m/%Y %H:%M") + 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): @@ -82,15 +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 - if command.args is None: - await message.reply(text="Вы не указали временной промежуток") - return - - 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) diff --git a/src/handlers/tables_handlers.py b/src/handlers/tables_handlers.py index 7690fd3..2bc9054 100644 --- a/src/handlers/tables_handlers.py +++ b/src/handlers/tables_handlers.py @@ -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) From b5fd9a80edf3e8211e181b173cef3a8bfab3e9ef Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:05:24 +0000 Subject: [PATCH 10/15] Resolve a missing date or interval when scheduling a rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /set_date passes no interval and /set_delta passes no date, but the missing half was only filled in when a job already existed. For a chat that had no job yet — start() skips chats the bot was not a member of at boot — /set_date reached IntervalTrigger with days=None and raised TypeError. A partially filled where_run entry was just as fatal later, since change_avatar adds delta to date unguarded. Resolve both values against the stored state before branching, falling back to now and to DEFAULT_DELTA, and always write the entry back whole. This adds the first tests to the project, so it also brings the pytest setup: the root conftest supplies the environment variables that src.bot.settings reads at import time. --- .gitignore | 1 + conftest.py | 16 +++++ pytest.ini | 3 + requirements-dev.txt | 5 ++ src/scheduler/scheduler.py | 28 +++++--- tests/test_scheduler.py | 127 +++++++++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 conftest.py create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/test_scheduler.py 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/conftest.py b/conftest.py new file mode 100644 index 0000000..303893e --- /dev/null +++ b/conftest.py @@ -0,0 +1,16 @@ +"""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. Living at the repository root +also puts that root on ``sys.path``, which is what makes ``import src`` work +from the tests. +""" + +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/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6f94355 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +asyncio_mode = auto diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f5e5065 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +pylint~=3.2 +pytest~=8.2 +pytest-asyncio~=0.23 diff --git a/src/scheduler/scheduler.py b/src/scheduler/scheduler.py index bb66351..872baea 100644 --- a/src/scheduler/scheduler.py +++ b/src/scheduler/scheduler.py @@ -10,6 +10,9 @@ 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.""" @@ -75,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, @@ -89,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/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} From a1aedb7fade045c0396d40d53e8d40fe3104b84e Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:05:24 +0000 Subject: [PATCH 11/15] Run the test suite in CI Also install the native libraries WeasyPrint and pdf2image need: the lint step only parsed the sources, while the tests import them. --- .github/workflows/dev.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index ac9bd1d..4231e74 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -15,13 +15,18 @@ jobs: with: python-version: '3.12' cache: 'pip' - - name: Install requirements + - name: Install system dependencies run: | - pip install -r requirements.txt - - name: Install linters + 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 pylint + pip install -r requirements-dev.txt - name: Check pylint run: | pylint src + - name: Run tests + run: | + pytest From ba7ff9da03c6b85b878493e4b177884a570ceea7 Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:17:29 +0000 Subject: [PATCH 12/15] Pin the dev tooling to the versions used locally CI resolved pylint 3.3 from the compatible-release range while the dev container ships 4.0.6, and the two disagree about R0917: 3.3 counts self as a positional argument, 4.0 does not, so three signatures that pass locally failed the lint step. Pin all three dev tools exactly. --- requirements-dev.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index f5e5065..eaae4dd 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,7 @@ -r requirements.txt -pylint~=3.2 -pytest~=8.2 -pytest-asyncio~=0.23 +# 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 From 9e7efdea28cf9052c4c6268a5904ac1116177856 Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:21:56 +0000 Subject: [PATCH 13/15] Put the repository root on sys.path for pytest The root carries an __init__.py, so pytest treats it as a package and inserts its parent directory instead of the root itself. `import src` therefore only resolved under `python -m pytest`, which injects the cwd on its own; the bare `pytest` that CI runs failed to collect at all. --- conftest.py | 7 ++++--- pytest.ini | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/conftest.py b/conftest.py index 303893e..8b364a2 100644 --- a/conftest.py +++ b/conftest.py @@ -2,9 +2,10 @@ ``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. Living at the repository root -also puts that root on ``sys.path``, which is what makes ``import src`` work -from the tests. +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 diff --git a/pytest.ini b/pytest.ini index 6f94355..1d18969 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +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 = . From 3bcb98daf41d64513f2b2d7be2c585163cc110dc Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 13:51:30 +0000 Subject: [PATCH 14/15] Update the dependencies to their current releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aiogram 3.5 -> 3.30 brings two breaking changes that the code had to follow: Bot() no longer takes parse_mode, so the default now goes through DefaultBotProperties, and Telegram models are frozen, so the media caption is set at construction instead of being assigned afterwards. Also declare python-dateutil, which jobs.py imports directly and which only arrived as a transitive dependency of pandas, and drop the typing backport — it targets Python below 3.5 and has no business in a 3.12 image. --- requirements.txt | 24 ++++++++++++------------ src/bot/instance.py | 6 +++++- src/scheduler/jobs.py | 5 ++++- src/utility/tools.py | 13 +++++++------ 4 files changed, 28 insertions(+), 20 deletions(-) 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/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/scheduler/jobs.py b/src/scheduler/jobs.py index 841aea7..6eba2fe 100644 --- a/src/scheduler/jobs.py +++ b/src/scheduler/jobs.py @@ -115,7 +115,10 @@ async def finish_survey(bot: Bot, request: Request, chat_id: int): ) 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 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 From 0ea21bf7fadf1f318a74d208fae1c889507d3958 Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Sun, 9 Aug 2026 18:18:59 +0400 Subject: [PATCH 15/15] Stop the fly proxy from shutting the bot down (#95) The app declared an http service on port 8080 with autostop enabled, but a long polling bot never listens on a port. The proxy therefore cordoned and stopped the machine roughly five minutes after every start, and autostart could not bring it back because no traffic ever reaches the app. Drop the service section so the worker simply runs. Also raise kill_timeout to 30s, which the graceful shutdown now needs, record the 768MB the machine already runs with, and remove the obsolete experimental block left over from the 2022 nomad config. --- fly.toml | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) 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