From 9e677a2e355a2ced7b7308406fa6f4785539209c Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Mon, 10 Aug 2026 10:13:06 +0000 Subject: [PATCH 1/3] Send /top as a native table instead of a rendered image Telegram has carried table blocks since Bot API 10.1, which the aiogram upgrade just made reachable. Hand /top its rows as data and let the client draw them: the result is selectable and searchable, and it skips the pandas -> WeasyPrint -> poppler -> Pillow round trip entirely. This is a pilot. /show_queue, /history and the monthly survey results still render images, so the pipeline and its dependencies stay for now. Also drop a leftover debug print from the handler. --- src/handlers/tables_handlers.py | 17 +++--- src/utility/rich_table.py | 72 +++++++++++++++++++++++++ tests/test_rich_table.py | 94 +++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 src/utility/rich_table.py create mode 100644 tests/test_rich_table.py diff --git a/src/handlers/tables_handlers.py b/src/handlers/tables_handlers.py index 2bc9054..014e410 100644 --- a/src/handlers/tables_handlers.py +++ b/src/handlers/tables_handlers.py @@ -5,11 +5,12 @@ from aiogram import Bot, Router, flags, types from aiogram.enums import ChatAction from aiogram.filters import Command, CommandObject -from aiogram.types import error_event +from aiogram.types import ReplyParameters, error_event from aiogram.utils import formatting from src.bot.settings import settings from src.database import Request +from src.utility.rich_table import table_message from src.utility.tools import table table_router = Router(name=__name__) @@ -43,7 +44,7 @@ async def show_queue(message: types.Message, request: Request) -> None: @table_router.message(Command("top")) -@flags.chat_action(action=ChatAction.UPLOAD_PHOTO) +@flags.chat_action(action=ChatAction.TYPING) async def top(message: types.Message, command: CommandObject, request: Request) -> None: """Show the top of the chat.""" @@ -62,7 +63,6 @@ async def top(message: types.Message, command: CommandObject, request: Request) data = [] try: data = await request.get_top(chat_id=chat_id, date=date) - print(data) except Exception as e: await message.reply( text=f"Не удалось получить топ от {date}\n\n" @@ -71,9 +71,14 @@ async def top(message: types.Message, command: CommandObject, request: Request) ) if len(data) > 0: - media = table(data, columns=["Nickname", "Trophies"], caption=caption) - if len(media) > 0: - await message.reply_media_group(media=media) + # Pilot of the native table block; the other commands still send images. + await message.bot.send_rich_message( + chat_id=chat_id, + rich_message=table_message( + data, columns=["Nickname", "Trophies"], caption=caption + ), + reply_parameters=ReplyParameters(message_id=message.message_id), + ) else: await message.reply("Список пуст!") diff --git a/src/utility/rich_table.py b/src/utility/rich_table.py new file mode 100644 index 0000000..9aee136 --- /dev/null +++ b/src/utility/rich_table.py @@ -0,0 +1,72 @@ +"""Build Telegram rich messages that carry a native table. + +The alternative is :func:`src.utility.tools.table`, which renders the same rows +to a PNG through pandas, WeasyPrint, poppler and Pillow. Telegram has carried +table blocks since Bot API 10.1, so the rows can be handed over as data and +rendered by the client instead — selectable, searchable and free of the whole +image pipeline. +""" + +from collections.abc import Iterable, Sequence +from typing import Any + +from aiogram.types import InputRichBlockTable, InputRichMessage, RichBlockTableCell + +# Numeric columns read better flush right; everything else stays left. +RIGHT_ALIGNED_COLUMNS = frozenset({"Trophies", "Score"}) + +# Header of the row-number column, matching the 1-based index of the old tables. +ROW_NUMBER_HEADER = "#" + + +def _cell(value: Any, align: str, is_header: bool = False) -> RichBlockTableCell: + """Wrap a single value into a table cell.""" + + return RichBlockTableCell( + align=align, + valign="middle", + text=str(value), + # Passing False would still mark the cell, so send nothing instead. + is_header=True if is_header else None, + ) + + +def table_message( + data: Iterable[Sequence[Any]], + columns: Sequence[str], + caption: str | None = None, +) -> InputRichMessage: + """Lay the rows out as a rich message holding one table. + + The first column numbers the rows, the way the rendered tables did through + the DataFrame index. + """ + + alignments = [ + "right" if column in RIGHT_ALIGNED_COLUMNS else "left" for column in columns + ] + + header = [_cell(ROW_NUMBER_HEADER, "right", is_header=True)] + header += [ + _cell(column, alignment, is_header=True) + for column, alignment in zip(columns, alignments) + ] + + rows = [] + for number, row in enumerate(data, start=1): + cells = [_cell(number, "right")] + cells += [ + _cell(value, alignment) for value, alignment in zip(row, alignments) + ] + rows.append(cells) + + return InputRichMessage( + blocks=[ + InputRichBlockTable( + cells=[header, *rows], + is_bordered=True, + is_striped=True, + caption=caption, + ) + ] + ) diff --git a/tests/test_rich_table.py b/tests/test_rich_table.py new file mode 100644 index 0000000..30e62fc --- /dev/null +++ b/tests/test_rich_table.py @@ -0,0 +1,94 @@ +"""Tests for :mod:`src.utility.rich_table`. + +The rendering itself belongs to the Telegram client, so what is worth checking +is the shape handed to the API: the header row, the row numbering that the +image tables got from the DataFrame index, and the alignment of numeric +columns. +""" + +from src.utility.rich_table import ROW_NUMBER_HEADER, table_message + +TOP_ROWS = [("nick1", 12), ("nick2", 7)] +TOP_COLUMNS = ["Nickname", "Trophies"] + + +def get_table(message): + """Return the single table block of a rich message.""" + + assert len(message.blocks) == 1, "expected exactly one block" + block = message.blocks[0] + assert block.type == "table" + return block + + +def test_header_row_is_marked_and_labelled(): + """The first row holds the column names and is flagged as a header.""" + + table = get_table(table_message(TOP_ROWS, TOP_COLUMNS)) + + header = table.cells[0] + assert [cell.text for cell in header] == [ROW_NUMBER_HEADER, "Nickname", "Trophies"] + assert all(cell.is_header for cell in header) + + +def test_rows_are_numbered_from_one(): + """The leading column reproduces the 1-based index of the rendered tables.""" + + table = get_table(table_message(TOP_ROWS, TOP_COLUMNS)) + + body = table.cells[1:] + assert len(body) == len(TOP_ROWS) + assert [row[0].text for row in body] == ["1", "2"] + assert [row[1].text for row in body] == ["nick1", "nick2"] + # Values are stringified — the API rejects a bare int in a cell. + assert [row[2].text for row in body] == ["12", "7"] + + +def test_body_cells_are_not_headers(): + """Only the first row may carry is_header, otherwise every row looks bold.""" + + table = get_table(table_message(TOP_ROWS, TOP_COLUMNS)) + + for row in table.cells[1:]: + assert all(cell.is_header is None for cell in row) + + +def test_numeric_columns_are_right_aligned(): + """Trophies is numeric, Nickname is not.""" + + table = get_table(table_message(TOP_ROWS, TOP_COLUMNS)) + + for row in table.cells: + number, nickname, trophies = row + assert number.align == "right" + assert nickname.align == "left" + assert trophies.align == "right" + + +def test_caption_is_carried_through(): + """The caption replaces the one the media group used to hold.""" + + table = get_table(table_message(TOP_ROWS, TOP_COLUMNS, caption="Топ за всё время")) + + assert table.caption == "Топ за всё время" + + +def test_empty_data_still_yields_a_header(): + """An empty table must not raise; the handlers guard against sending it.""" + + table = get_table(table_message([], TOP_COLUMNS)) + + assert len(table.cells) == 1 + + +def test_message_is_serialisable_for_the_api(): + """The whole message must survive the dump aiogram sends over the wire.""" + + message = table_message(TOP_ROWS, TOP_COLUMNS, caption="Топ") + + dumped = message.model_dump(exclude_none=True) + assert set(dumped) == {"blocks"}, "only blocks may be set, not html or markdown" + table = dumped["blocks"][0] + assert table["type"] == "table" + assert table["is_bordered"] is True + assert len(table["cells"]) == len(TOP_ROWS) + 1 From da2b3c72724749d71dbb4012989162d5a24e8e7c Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Mon, 10 Aug 2026 11:08:31 +0000 Subject: [PATCH 2/3] Drop the image pipeline in favour of native tables /show_queue, /history and the monthly survey results now travel as table blocks like /top already did, so nothing renders PNGs any more. That removes pandas, WeasyPrint, pdf2image and Pillow from the requirements, poppler and pango from the image, and src/utility/tools.py altogether. The survey used to send three images as a media group and pin the first one, which pinned only the first category. It is now a single message: a heading followed by one table per category, so pinning covers all three. --- .github/workflows/dev.yml | 5 --- Dockerfile | 2 - requirements.txt | 4 -- src/handlers/tables_handlers.py | 31 +++++++++------ src/scheduler/jobs.py | 30 ++++++++------ src/utility/rich_table.py | 65 +++++++++++++++++++----------- src/utility/tools.py | 70 --------------------------------- tests/test_rich_table.py | 38 +++++++++++++++++- 8 files changed, 116 insertions(+), 129 deletions(-) delete mode 100644 src/utility/tools.py diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 4231e74..3474b38 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -15,11 +15,6 @@ jobs: 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 diff --git a/Dockerfile b/Dockerfile index c5a3ad4..31b7847 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,6 @@ WORKDIR /opt/src ENV PYTHONUNBUFFERED 1 ENV PYTHONDONTWRITEBYTECODE 1 -RUN apk add --no-cache poppler-utils pango font-noto - COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/requirements.txt b/requirements.txt index 9531873..e287708 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,7 @@ 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/handlers/tables_handlers.py b/src/handlers/tables_handlers.py index 014e410..6eae1a6 100644 --- a/src/handlers/tables_handlers.py +++ b/src/handlers/tables_handlers.py @@ -11,13 +11,12 @@ from src.bot.settings import settings from src.database import Request from src.utility.rich_table import table_message -from src.utility.tools import table table_router = Router(name=__name__) @table_router.message(Command("show_queue")) -@flags.chat_action(action=ChatAction.UPLOAD_PHOTO) +@flags.chat_action(action=ChatAction.TYPING) async def show_queue(message: types.Message, request: Request) -> None: """Show the queue of trophies.""" @@ -34,11 +33,15 @@ async def show_queue(message: types.Message, request: Request) -> None: ) if len(data) > 0: - media = table( - data, columns=["Nickname", "Game", "Platform"], caption="Очередь трофеев" + await message.bot.send_rich_message( + chat_id=chat_id, + rich_message=table_message( + data, + columns=["Nickname", "Game", "Platform"], + caption="Очередь трофеев", + ), + reply_parameters=ReplyParameters(message_id=message.message_id), ) - if len(media) > 0: - await message.reply_media_group(media=media) else: await message.reply(text="Список пуст!") @@ -84,7 +87,7 @@ async def top(message: types.Message, command: CommandObject, request: Request) @table_router.message(Command("history")) -@flags.chat_action(action=ChatAction.UPLOAD_PHOTO) +@flags.chat_action(action=ChatAction.TYPING) async def get_history(message: types.Message, command: CommandObject, request: Request): """Get the history of the user's trophies.""" @@ -112,13 +115,15 @@ async def get_history(message: types.Message, command: CommandObject, request: R if len(data) > 0: display_name = ("@" + username) if username else message.from_user.full_name - media = table( - data, - columns=["Game", "Date", "Platform"], - caption=f"Список всех трофеев: {display_name}", + await message.bot.send_rich_message( + chat_id=chat_id, + rich_message=table_message( + data, + columns=["Game", "Date", "Platform"], + caption=f"Список всех трофеев: {display_name}", + ), + reply_parameters=ReplyParameters(message_id=message.message_id), ) - if len(media) > 0: - await message.reply_media_group(media=media) else: await message.reply("Список пуст!") diff --git a/src/scheduler/jobs.py b/src/scheduler/jobs.py index 6eba2fe..898f6b5 100644 --- a/src/scheduler/jobs.py +++ b/src/scheduler/jobs.py @@ -12,7 +12,7 @@ from src.database.connect import Request from src.database.schemas import Scores from src.keyboards.inline import GameSurveyCallbackData, build_start_survey_keyboard -from src.utility.tools import table +from src.utility.rich_table import heading_block, rich_message, table_block async def change_avatar(bot: Bot, request: Request, chat_id: int, where_run: dict): @@ -79,7 +79,7 @@ async def finish_survey(bot: Bot, request: Request, chat_id: int): # Get Last day of the previous month date_end = now.replace(day=1, hour=0, minute=0, second=0) - media = [] + tables = [] trophy_ids = set() for score in (Scores.game, Scores.picture, Scores.difficulty): match score: @@ -100,13 +100,13 @@ async def finish_survey(bot: Bot, request: Request, chat_id: int): trophy_ids.update(row[0] for row in results) - media.extend(table(data=[(row[1], row[2], row[3]) for row in results], - columns=["Hunter", "Game", "Score"], - name=text)) + tables.append(table_block(data=[(row[1], row[2], row[3]) for row in results], + columns=["Hunter", "Game", "Score"], + caption=text)) await request.add_survey_history(score_type=score.key, data=[(row[0], row[3]) for row in results]) - if len(media) == 0: + if len(tables) == 0: logging.warning( ( f"{__name__}: The survey has not been conducted " @@ -114,15 +114,21 @@ async def finish_survey(bot: Bot, request: Request, chat_id: int): ) ) else: - # Send tables with survey results - # 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')}"} + # One message titled by a heading, with a table per category. + survey_message = await bot.send_rich_message( + chat_id=chat_id, + rich_message=rich_message( + [ + heading_block( + f"Результаты опроса за {date_start.strftime('%m.%Y')}" + ), + *tables, + ] + ), ) - 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) + await bot.pin_chat_message(chat_id=chat_id, message_id=survey_message.message_id) # Delete scores for calculated trophy_ids await request.delete_scores(trophy_ids=trophy_ids) diff --git a/src/utility/rich_table.py b/src/utility/rich_table.py index 9aee136..11e7e80 100644 --- a/src/utility/rich_table.py +++ b/src/utility/rich_table.py @@ -1,16 +1,21 @@ -"""Build Telegram rich messages that carry a native table. +"""Build Telegram rich messages that carry native tables. -The alternative is :func:`src.utility.tools.table`, which renders the same rows -to a PNG through pandas, WeasyPrint, poppler and Pillow. Telegram has carried -table blocks since Bot API 10.1, so the rows can be handed over as data and -rendered by the client instead — selectable, searchable and free of the whole -image pipeline. +Telegram has carried table blocks since Bot API 10.1, so the rows are handed +over as data and drawn by the client. That replaces rendering them to a PNG +through pandas, WeasyPrint, poppler and Pillow: the result is selectable and +searchable, and the bot needs no native libraries at all. """ from collections.abc import Iterable, Sequence from typing import Any -from aiogram.types import InputRichBlockTable, InputRichMessage, RichBlockTableCell +from aiogram.types import ( + InputRichBlockSectionHeading, + InputRichBlockTable, + InputRichBlockUnion, + InputRichMessage, + RichBlockTableCell, +) # Numeric columns read better flush right; everything else stays left. RIGHT_ALIGNED_COLUMNS = frozenset({"Trophies", "Score"}) @@ -31,12 +36,12 @@ def _cell(value: Any, align: str, is_header: bool = False) -> RichBlockTableCell ) -def table_message( +def table_block( data: Iterable[Sequence[Any]], columns: Sequence[str], caption: str | None = None, -) -> InputRichMessage: - """Lay the rows out as a rich message holding one table. +) -> InputRichBlockTable: + """Lay the rows out as one table block. The first column numbers the rows, the way the rendered tables did through the DataFrame index. @@ -55,18 +60,34 @@ def table_message( rows = [] for number, row in enumerate(data, start=1): cells = [_cell(number, "right")] - cells += [ - _cell(value, alignment) for value, alignment in zip(row, alignments) - ] + cells += [_cell(value, alignment) for value, alignment in zip(row, alignments)] rows.append(cells) - return InputRichMessage( - blocks=[ - InputRichBlockTable( - cells=[header, *rows], - is_bordered=True, - is_striped=True, - caption=caption, - ) - ] + return InputRichBlockTable( + cells=[header, *rows], + is_bordered=True, + is_striped=True, + caption=caption, ) + + +def heading_block(text: str, size: int = 3) -> InputRichBlockSectionHeading: + """A section heading, used to title a message holding several tables.""" + + return InputRichBlockSectionHeading(text=text, size=size) + + +def rich_message(blocks: Sequence[InputRichBlockUnion]) -> InputRichMessage: + """Wrap blocks into a message ready for sendRichMessage.""" + + return InputRichMessage(blocks=list(blocks)) + + +def table_message( + data: Iterable[Sequence[Any]], + columns: Sequence[str], + caption: str | None = None, +) -> InputRichMessage: + """Shorthand for a message holding a single table.""" + + return rich_message([table_block(data, columns, caption)]) diff --git a/src/utility/tools.py b/src/utility/tools.py deleted file mode 100644 index f8d6dc1..0000000 --- a/src/utility/tools.py +++ /dev/null @@ -1,70 +0,0 @@ -"""This module contains various tools for working with images and PDFs.""" - -from io import BytesIO - -import pandas as pd -import weasyprint as wsp -from aiogram import types -from pdf2image import convert_from_bytes -from PIL import Image, ImageChops - - -def trim(src): - """Trim the image.""" - - background = src.getpixel((0, 0)) - border = Image.new(src.mode, src.size, background) - diff = ImageChops.difference(src, border) - bbox = diff.getbbox() - img = src.crop(bbox) if bbox else src - - return img - - -def table(data, columns, caption: str = None, name: str = None): - """Generate a table from the data.""" - - # Create DataFrame - df = pd.DataFrame(data, columns=columns) - df.index += 1 - - css_text = ( - "@page { size: 800px 715px; padding: 0px; margin: 0px; }\n" - "h4 {text-align: start;}\n" - "table, td, tr, th { border: 1px solid black; }\n" - "th { padding: 4px 8px; background-color: lightgray; }\n" - "td { padding: 4px 8px; }\n" - "th, td {text-align: center; }\n" - ) - - if "Game" in columns: - css_text += ( - f"td:nth-child({columns.index('Game') + 2}) {{ text-align: left; }}\n" - ) - - # Generate CSS styles - css = wsp.CSS(string=css_text) - # Generate HTML with CSS - html_string = df.to_html() - if name is not None: - html_string = f"

{name}

\n" + html_string - html = wsp.HTML(string=html_string) - pages = convert_from_bytes(html.write_pdf(stylesheets=[css]), dpi=100) - - media = [] - for i, page in enumerate(pages): - trimmed = trim(page) - img = BytesIO() - trimmed.save(img, "PNG") - img.seek(0) - - # 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, - ) - ) - - return media diff --git a/tests/test_rich_table.py b/tests/test_rich_table.py index 30e62fc..92b26f9 100644 --- a/tests/test_rich_table.py +++ b/tests/test_rich_table.py @@ -6,7 +6,13 @@ columns. """ -from src.utility.rich_table import ROW_NUMBER_HEADER, table_message +from src.utility.rich_table import ( + ROW_NUMBER_HEADER, + heading_block, + rich_message, + table_block, + table_message, +) TOP_ROWS = [("nick1", 12), ("nick2", 7)] TOP_COLUMNS = ["Nickname", "Trophies"] @@ -81,6 +87,36 @@ def test_empty_data_still_yields_a_header(): assert len(table.cells) == 1 +def test_survey_results_fit_one_message(): + """finish_survey sends a heading followed by one table per category. + + The three categories used to travel as a media group of three images; they + now have to reach the chat as a single message so that pinning it pins all + of them. + """ + + categories = ["Игра", "Картинка", "Сложность"] + message = rich_message( + [ + heading_block("Результаты опроса за 07.2026"), + *( + table_block( + [("nick1", "Bloodborne", 9.5)], + columns=["Hunter", "Game", "Score"], + caption=f"Результаты в категории {category}", + ) + for category in categories + ), + ] + ) + + assert [block.type for block in message.blocks] == ["heading", "table", "table", "table"] + assert message.blocks[0].text == "Результаты опроса за 07.2026" + assert [block.caption for block in message.blocks[1:]] == [ + f"Результаты в категории {category}" for category in categories + ] + + def test_message_is_serialisable_for_the_api(): """The whole message must survive the dump aiogram sends over the wire.""" From 5ca99f41aaccf7c233a3325a33bd0fb6ca81eddb Mon Sep 17 00:00:00 2001 From: Artem Kopytin Date: Mon, 10 Aug 2026 11:10:19 +0000 Subject: [PATCH 3/3] Set 512mb for VM --- fly.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fly.toml b/fly.toml index 7c0d692..5c126f6 100644 --- a/fly.toml +++ b/fly.toml @@ -17,5 +17,5 @@ kill_timeout = "30s" [[vm]] size = "shared-cpu-1x" - memory = "768mb" + memory = "512mb" processes = ["worker"]