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/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"] 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 2bc9054..6eae1a6 100644 --- a/src/handlers/tables_handlers.py +++ b/src/handlers/tables_handlers.py @@ -5,18 +5,18 @@ 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.tools import table +from src.utility.rich_table import table_message 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.""" @@ -33,17 +33,21 @@ 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="Список пуст!") @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 +66,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,15 +74,20 @@ 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("Список пуст!") @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.""" @@ -107,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 new file mode 100644 index 0000000..11e7e80 --- /dev/null +++ b/src/utility/rich_table.py @@ -0,0 +1,93 @@ +"""Build Telegram rich messages that carry native tables. + +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 ( + InputRichBlockSectionHeading, + InputRichBlockTable, + InputRichBlockUnion, + 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_block( + data: Iterable[Sequence[Any]], + columns: Sequence[str], + caption: str | None = None, +) -> 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. + """ + + 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 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 new file mode 100644 index 0000000..92b26f9 --- /dev/null +++ b/tests/test_rich_table.py @@ -0,0 +1,130 @@ +"""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, + heading_block, + rich_message, + table_block, + 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_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.""" + + 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