Обновить
8K+
1

Python, Web & Telegram Bots, Linux, C/C++

0,3
Рейтинг
Отправить сообщение

Спасибо за статью и за просьбу присылать воспроизводимые случаи. Проверил описанный TODO: в исходной версии принималась программа, которая читает байт стека без предшествующей записи (LDX(BPF_B, r0, r10, -1); EXIT).

Подготовил исправление и открыл PR #1: побайтовый учёт инициализации стека, пересечение состояний на слиянии веток и проверки чтений через инструкции и helper-функции с контрактом указатель+длина. Добавил регрессионные тесты для частичной записи и ветвлений.

test_vm: 59/59 под ASan/UBSan. Полный make test в этом окружении завершить не удалось: установленный Clang не поддерживает BPF target, поэтому примеры eBPF не собрались.

А таймаут останавливает команду на сервере или только ожидание ответа в боте? Например, установка пакета не уложилась в лимит: пользователю важно понять, она ещё идёт или уже завершена. Я бы в таком случае показывал «результат пока неизвестен» и давал проверить состояние операции, прежде чем запускать её повторно.

А как тематический поиск обрабатывает русские словоформы? Например, найдёт ли запрос «разработка бота» пост со словами «разработка ботов»? Для новостей это полезно проверить отдельно: пустая выдача ещё не означает, что тема в архиве отсутствует. Было бы интересно увидеть пару таких запросов и понять, хватает ли FTS5 или перед поиском есть нормализация.

Тут остаётся случай, когда крон вообще не запустил скрипт: внутренняя проверка файла тоже не выполнится, и Telegram снова промолчит. Я бы проверял свежесть результата отдельным заданием, а для критичного процесса — с другой машины. Тогда тревога приходит и при падении шага, и при отсутствии самого запуска.

А как устроен откат после правок уже работающего бота? Например, агент поменял схему SQLite, новая версия не запустилась, а пользователи до этого оставляли заявки. Вернуть старый код тут может быть недостаточно. Было бы полезно показать второй пример: обновление бота с данными, резервная копия базы и восстановление после неудачного деплоя.

В реконструкции таймер запускается от первого сообщения в пачке. Тогда на последнюю реплику ответ может прийти почти сразу, если она отправлена в конце этих 270 секунд. Интересно было бы сравнить интервалы по экспорту чата с точностью до секунд: это помогло бы отличить такую очередь от задержки после каждого сообщения. По минутам на скринах обе версии выглядят похоже.

Привязка разрешения к конкретному действию — хорошая деталь. А у ожидания ответа есть срок жизни? Если запрос остался в чате, а задача уже остановлена или продолжена новым процессом, я бы делал старую кнопку явно неактивной и показывал причину. Иначе с телефона трудно понять, агент ещё ждёт решения или карточка уже устарела.

А у consult_colleague есть общий лимит на число консультаций в рамках одного ответа? Если коллега тоже может вызвать этот инструмент, возможна цепочка А → Б → А. Я бы ограничивал всю цепочку одним бюджетом и передавал список уже вызванных персонажей, чтобы повторные обращения не съедали время и токены без нового результата.

У strip_ai_markers замена идёт по всему тексту, включая ссылки и код. Если в пути URL есть «–», после замены на «-» это уже другой адрес. Я бы исключил такие фрагменты из фильтра и добавил тест на сохранение ссылок: убрать тире можно успешно, а полезную ссылку при этом сломать.

Копия состояния для PDF — хорошая деталь: новое предложение не испортит уже заказанный документ. А на запуск Chrome есть таймаут с завершением процесса? При одном воркере зависший рендер остановит всю очередь, хотя сам бот продолжит отвечать. Я бы добавил этот случай в раздел про эксплуатацию.

А сообщения из супергруппы в MAX пересылаются только от владельца привязанного аккаунта? Если он случайно добавит ещё одного участника, одного ID группы для проверки доступа уже мало. Интересно, проверяете ли отправителя и для обычных сообщений, и для правок с реакциями.

С удалением черновика до отправки есть ещё один сценарий: процесс упал между удалением файла и запросом к площадке. Как такой пост восстанавливается? Я бы сохранял черновик со статусом «отправляется», а при таймауте отмечал результат как неизвестный — чтобы можно было проверить публикацию и решить, нужен ли повтор.

А если один пользователь откроет два веб-диалога, у них будет общий lock или отдельный на каждую PTBSession? user_id ведь остаётся прежним. Если состояние бота привязано к нему, я бы сериализовал апдейты по user_id, иначе две вкладки могут одновременно менять один сценарий.

Нашёл крайний случай в COCO Panoptic export. Если изображения пришли из разных каталогов, но называются одинаково (например, road_a/frame.jpg и road_b/frame.jpg), экспорт складывает их в плоские папки и использует только basename/stem. При copy_images=True один файл перезапишет другой; panoptic PNG тоже перезапишется, а в JSON останутся две записи с одинаковыми именами.

Минимальный патч в цикле по img_records:

- coco_images.append({"id": img_id, "file_name": img_path.name,
+ image_file_name = f"{img_id}_{img_path.name}"
+ coco_images.append({"id": img_id, "file_name": image_file_name,
                      "width": iw, "height": ih})
- png_name = f"{img_path.stem}.png"
+ png_name = f"{img_id}_{img_path.stem}.png"
...
- shutil.copy2(img_path, img_out / img_path.name)
+ shutil.copy2(img_path, img_out / image_file_name)

Проверка для пары путей с одинаковым basename: после такого префикса имена изображений (0_frame.jpg, 1_frame.jpg) и масок (0_frame.png, 1_frame.png) уникальны. Эту проверку прогнал отдельно; полный набор проекта здесь не запускал.

Нашёл ещё один пограничный случай в показанном _bot_api_request. CallbackQueryHandler обычно вызывает await query.answer(); в PTB 20.7 это запрос answerCallbackQuery, а его нет в списке поддержанных методов — обработчик попадёт в ветку not implemented. В браузерном режиме это можно подтвердить без побочного эффекта:

if action == "answercallbackquery":
    return ok(True)

Я бы добавил тест: callback-handler вызывает query.answer(), затем редактирует сообщение; проверяем, что transport не падает и edit доходит до сессии. PTB отдельно требует отвечать на callback даже без текста, иначе клиент оставляет индикатор загрузки: https://docs.python-telegram-bot.org/en/v20.7/telegram.callbackquery.html

Пока смотрел LongCallbackData, проверил Unicode-префикс. У Telegram лимит callback_data считается в байтах. При prefix=“🐍” * 10 длинный payload переходит на короткий ID, но _UniqueIDCallbackData.build() считает префикс через len(str) и генерирует ID длиннее 64 байт; затем падает pack(). Воспроизвёл до патча.

В расчёте заменил длины строк на длины UTF-8. Тест проверяет лимит и восстановление payload через фильтр. По callback-наборам прошло 14 тестов; шесть параметров с Redis пропустил, так как локальный Redis недоступен.

Полный diff:

diff --git a/aiogram_tool/tools/callback_data/filter.py b/aiogram_tool/tools/callback_data/filter.py
index 00d096c..0e8e648 100644
--- a/aiogram_tool/tools/callback_data/filter.py
+++ b/aiogram_tool/tools/callback_data/filter.py
@@ -20,9 +20,14 @@ class _UniqueIDCallbackData(CallbackData, prefix=UNIQUE_PREFIX):
 
     @classmethod
     def build(cls, callback_data: type[CallbackData]) -> Self:
-        separators_len = len(cls.__separator__) * len(cls.model_fields)
+        separators_len = len(cls.__separator__.encode("utf-8")) * len(cls.model_fields)
         unique_id_len = (
-            64 - (len(UNIQUE_PREFIX) + len(callback_data.__prefix__) + separators_len)
+            64
+            - (
+                len(UNIQUE_PREFIX.encode("utf-8"))
+                + len(callback_data.__prefix__.encode("utf-8"))
+                + separators_len
+            )
         ) // 2
 
         if unique_id_len < 6:
diff --git a/tests/callback_data/test_unique_id.py b/tests/callback_data/test_unique_id.py
index 0255395..7c83e4a 100644
--- a/tests/callback_data/test_unique_id.py
+++ b/tests/callback_data/test_unique_id.py
@@ -1,6 +1,13 @@
 import pytest
 
-from aiogram_tool.tools.callback_data.filter import LongCallbackData
+from aiogram_tool.storage.impl.memory import MemoryStorage
+from aiogram_tool.tools.callback_data.filter import (
+    LongCallbackData,
+    LongCallbackQueryFilter,
+    _UniqueIDCallbackData,
+)
+
+from .conftest import CallbackQueryFactoryType
@@
         # Error is too long prefix
         await Data(attr="important_data").pack_long()
+
+
+async def test_unicode_prefix_unique_id_respects_telegram_byte_limit(
+    callback_query_factory: CallbackQueryFactoryType,
+) -> None:
+    class Data(LongCallbackData, prefix="🐍" * 10):
+        _storage = MemoryStorage()
+
+        attr: str
+
+    unique_id = _UniqueIDCallbackData.build(Data).pack()
+    assert len(unique_id.encode("utf-8")) <= 64
+
+    packed = await Data(attr="important_data" * 10).pack_long()
+    assert len(packed.encode("utf-8")) <= 64
+
+    result = await LongCallbackQueryFilter(callback_data=Data)(
+        callback_query_factory(packed)
+    )
+    assert result["callback_data"] == Data(attr="important_data" * 10)

Да, согласен: библиотека не должна угадывать ACL по имени поля. Я имел в виду пример с owner_id и явным сравнением с query.from_user.id в хэндлере — так назначение ID видно, а проверка остаётся на стороне приложения. Спасибо, уточнил.

Нашёл конкретную поломку из списка «сырого»: MarkdownHeaderTextSplitter запускается для каждой страницы отдельно, поэтому на продолжении параграфа заголовка уже нет и фильтр по нему отбрасывает фрагмент.

В статье нет ссылки на репозиторий, поэтому проверил только этот фрагментный фикс, не весь бот. Заголовки можно переносить между страницами так:

HEADER_LEVELS = ("Глава", "Параграф", "Подраздел")


def carry_header_metadata(chunks, active=None):
    current = dict(active or {})
    for chunk in chunks:
        metadata = chunk.metadata
        if "Глава" in metadata:
            current = {"Глава": metadata["Глава"]}
        if "Параграф" in metadata:
            current["Параграф"] = metadata["Параграф"]
            current.pop("Подраздел", None)
        if "Подраздел" in metadata:
            current["Подраздел"] = metadata["Подраздел"]
        for key in HEADER_LEVELS:
            if key not in metadata and key in current:
                metadata[key] = current[key]
    return current

active_headers = {}
for page_dict in pages_data:
    page_text = page_dict["text"]
    page_num = page_dict["metadata"].get("page", 0) + 1
    md_chunks = markdown_splitter.split_text(page_text)
    active_headers = carry_header_metadata(md_chunks, active_headers)
    safe_chunks = text_splitter.split_documents(md_chunks)
    for chunk in safe_chunks:
        chunk.metadata["Страница"] = page_num
        chunk.metadata["Класс"] = grade
        all_safe_chunks.append(chunk)

Три теста на продолжение страницы, смену главы и смену параграфа прошли (3/3):

from dataclasses import dataclass, field
from page_metadata import carry_header_metadata

@dataclass
class Chunk:
    metadata: dict = field(default_factory=dict)

def test_page_continuation_keeps_paragraph_and_own_page():
    first = [Chunk({"Глава": "Трудовое право", "Параграф": "§ 23"})]
    active = carry_header_metadata(first)
    second = [Chunk({"Страница": 140})]
    carry_header_metadata(second, active)
    assert second[0].metadata == {
        "Страница": 140, "Глава": "Трудовое право", "Параграф": "§ 23"
    }

def test_new_chapter_clears_old_paragraph():
    active = {"Глава": "Трудовое право", "Параграф": "§ 23", "Подраздел": "Стороны"}
    chunks = [Chunk({"Глава": "Семейное право"})]
    assert carry_header_metadata(chunks, active) == {"Глава": "Семейное право"}

def test_new_paragraph_clears_old_subsection():
    active = {"Глава": "Трудовое право", "Параграф": "§ 23", "Подраздел": "Стороны"}
    chunks = [Chunk({"Параграф": "§ 24"})]
    assert carry_header_metadata(chunks, active) == {
        "Глава": "Трудовое право", "Параграф": "§ 24"
    }

Попробовал JSON-импорт на текущем коммите aa2e733. Нашёл небольшой сбой на грязных данных: одна запись с rating: 1e400 прерывает загрузку всего файла, включая следующий нормальный отзыв. Число при разборе JSON становится inf, а parse_int падает на round(value) с OverflowError. Для NaN, который Python тоже принимает при чтении JSON, получается ValueError.

Проверил поправку через math.isfinite: неконечное значение рейтинга превращается в None, появляется существующее предупреждение, импорт продолжается. Тесты проверяют и проблемную запись, и следующий отзыв с оценкой 5. До патча три случая падали; после проходят все 46 тестов импорта вместе с новыми, Python 3.12. Линтер тоже прошёл. Аналитический pipeline и модель не запускал.

Патч:

diff --git a/src/reviewscope/ingestion/normalize.py b/src/reviewscope/ingestion/normalize.py
index edd51a9..6d1f6ae 100644
--- a/src/reviewscope/ingestion/normalize.py
+++ b/src/reviewscope/ingestion/normalize.py
@@ -17,6 +17,7 @@ rejected and counted as skipped.
 from __future__ import annotations
 
 import datetime as _dt
+import math
 import re
 from typing import Any
 
@@ -121,6 +122,8 @@ def parse_int(value: Any) -> int | None:
     if isinstance(value, int):
         return value
     if isinstance(value, float):
+        if not math.isfinite(value):
+            return None
         return int(round(value))
     text = str(value).strip()
     if not text:

Тесты — сохранить как tests/test_nonfinite_rating.py:

import pytest

from reviewscope.ingestion.json_adapter import JSONAdapter


@pytest.mark.parametrize("rating", ["1e400", "-1e400", "NaN"])
def test_invalid_numeric_rating_does_not_abort_json_import(tmp_path, rating):
    path = tmp_path / "reviews.json"
    path.write_text(
        '[{"review_id":"bad","place_id":"p","reviewer_id":"u",'
        f'"rating":{rating},"text":"bad rating"}},'
        '{"review_id":"good","place_id":"p","reviewer_id":"u2",'
        '"rating":5,"text":"normal review"}]',
        encoding="utf-8",
    )
    result = JSONAdapter().load(path)
    assert [review.review_id for review in result.reviews] == ["bad", "good"]
    assert result.reviews[0].rating is None
    assert result.reviews[1].rating == 5
    assert result.report.valid == 2
    assert result.report.warning_count == 1
    assert "rating" in result.report.warnings[0]

Из корня проекта, в отдельном виртуальном окружении:

python -m pip install pandas pydantic pytest
PYTHONPATH=src python -m pytest tests/test_nonfinite_rating.py tests/test_ingestion.py

По проверке владельца кнопки согласен: это задача хэндлера, универсально угадывать её по имени поля не стоит.

Забрал актуальную 1.2.3 (коммит b8cfa85 от 10 октября) и нашёл отдельный случай в DI. Если хэндлер падает, зависимость с yield не получает его исключение: AsyncExitStackTransaction вызывает aclose(), то есть закрывает стек как при успехе. В тесте ветка except у зависимости не выполнялась, вместо неё выполнялась else. Для зависимости, которая выбирает commit или rollback по результату обработки, это существенно.

Проверил замену aclose() на aexit(*args), чтобы передать исходное исключение. Два новых теста на REQUEST и TRANSIENT до патча падали, после него все 33 теста DI проходят на Python 3.12; ruff check и format --check тоже проходят. Redis и Telegram для этих тестов не нужны. Полный набор проекта с Redis не запускал.

Патч:

diff --git a/aiogram_tool/tools/depend/utils/stack_manager.py b/aiogram_tool/tools/depend/utils/stack_manager.py
index dd7c838..16517b1 100644
--- a/aiogram_tool/tools/depend/utils/stack_manager.py
+++ b/aiogram_tool/tools/depend/utils/stack_manager.py
@@ -13,7 +13,7 @@ class AsyncExitStackTransaction:
         return self
 
     async def __aexit__(self, *args) -> None:
-        await self.stack.aclose()
+        await self.stack.__aexit__(*args)
 
     def _get_stack(self, scope: Scope) -> AsyncExitStack:
         return self.app_stack if scope == Scope.SINGLETON else self.stack

Тесты — сохранить в tests/depend/test_stack_exception.py:

from contextlib import AsyncExitStack, asynccontextmanager

import pytest

from aiogram_tool.tools.depend.types.enums import Scope
from aiogram_tool.tools.depend.utils.stack_manager import AsyncExitStackTransaction


@pytest.mark.parametrize("scope", [Scope.REQUEST, Scope.TRANSIENT])
async def test_dependency_receives_handler_exception(scope):
    seen = []

    @asynccontextmanager
    async def dependency():
        try:
            yield
        except RuntimeError as exc:
            seen.append(exc)
            raise
        else:
            seen.append("success")

    error = RuntimeError("handler failed")
    async with AsyncExitStack() as app_stack:
        with pytest.raises(RuntimeError) as caught:
            async with AsyncExitStackTransaction(app_stack) as transaction:
                await transaction.enter_async_context(dependency(), scope)
                raise error
    assert caught.value is error
    assert seen == [error]


async def test_dependency_receives_success():
    seen = []

    @asynccontextmanager
    async def dependency():
        yield
        seen.append("success")

    async with AsyncExitStack() as app_stack:
        async with AsyncExitStackTransaction(app_stack) as transaction:
            await transaction.enter_async_context(dependency(), Scope.REQUEST)
    assert seen == ["success"]

Из корня репозитория, в отдельном виртуальном окружении:

python -m pip install aiogram==3.29.1 "redis>=8.0.0" pytest pytest-asyncio
python -m pytest tests/depend -q

Информация

В рейтинге
2 460-й
Откуда
Владивосток, Приморский край, Россия
Зарегистрирован
Активность

Специализация

Фулстек разработчик, Веб-разработчик
Стажёр
От 30 000 ₽
Python
Linux