Skip to content

Загрузка голосовых всегда падает по таймауту: MAX не присылает NOTIF_ATTACH, которого ждёт upload_voice #102

Description

@azatsh

Кратко

UploadService.upload_voice отправляет аудио POST-запросом, получает HTTP 200 и затем 60 секунд
ждёт NOTIF_ATTACH (opcode 136), который сервер не присылает никогда. Любая отправка голосового
падает с UploadError: Timed out waiting for voice processing. При этом загрузка File в той же
сессии и по тому же соединению получает свой opcode 136 меньше чем за секунду.

Принудительный пропуск ожидания не помогает: сервер отклоняет сообщение с ошибкой
errors.process.attachment.video.not.supported. То есть уведомление обязательно — это не просто
индикатор прогресса.

Отдельно есть скрытая проблема в том, как голосовой NOTIF_ATTACH маршрутизировался бы и по какому
ключу искался бы, если бы всё-таки пришёл (Находка 3) — это вывод из чтения исходников, а не
наблюдение.

Окружение

maxapi-python 2.4.0
Python 3.12
ОС Linux (Docker)
Сервер api.oneme.ru
Аккаунт обычный личный аккаунт MAX
Данные голосовые из Telegram, Opus в контейнере Ogg, 29 КБ и 57 КБ
Логирование ExtraConfig(log_level="DEBUG")

Воспроизведение

ogg_bytes = Path("voice.ogg").read_bytes()  # любое обычное голосовое из Telegram
await client.send_message(
    chat_id,
    "caption",
    attachments=[Voice(raw=ogg_bytes, name="voice.ogg")],
)

Ожидается: сообщение уходит, в чате появляется голосовой пузырь.
По факту: send_message висит 60 секунд, затем бросает
UploadError("Timed out waiting for voice processing voice_id=...").

Воспроизводится каждый раз — 5+ попыток в нескольких запусках, два разных исходных файла, ни одной
успешной отправки.


Находка 1 — MAX не присылает уведомление о голосовом вложении

upload_voice регистрирует waiter, отправляет POST и уходит в ожидание
(pymax/api/uploads/service.py:251 и :287):

16:39:49  Voice upload waiter registered voice_id=3698883559635
16:39:49  Voice upload HTTP response status=200 voice_id=3698883559635
16:39:49  Waiting for voice processing notification voice_id=3698883559635
          [следующие 60 с: только кадры opcode=49 CHAT_HISTORY и opcode=1 PING]
16:40:49  Timed out waiting for voice processing notification voice_id=3698883559635

Загрузка File минутой позже, в том же запуске и по тому же соединению, отрабатывает ровно так,
как задумано:

16:40:49  File upload waiter registered file_id=4760217947
16:40:50  File upload HTTP response status=200 file_id=4760217947
16:40:50  dispatching event type=EventType.FILE_READY
16:40:50  calling handler event=EventType.FILE_READY callback=UploadService.on_file_attach
16:40:50  File upload waiter resolved file_id=4760217947

За весь запуск opcode 136 пришёл ровно один раз — для файла. После голосового POST не пришло
вообще ни одного кадра.

Находка 2 — если пропустить ожидание, отправку отклоняет сервер

Мы проверили гипотезу, что ожидание ничего не даёт: upload_voice отбрасывает значение future и
собирает возвращаемый payload целиком из более раннего ответа на VIDEO_UPLOAD
(pymax/api/uploads/service.py:298):

return VideoAttachPayload(type=AttachmentType.AUDIO, video_id=video_id, token=token)

Если подменить uploads.voice_upload_waiters на словарь, который завершает future прямо в момент
регистрации, загрузка «успешно» проходит мгновенно. После этого MSG_SEND отклоняется:

Key: errors.process.attachment.video.not.supported [video.not.supported]

Значит, уведомление действительно необходимо: без той серверной обработки, о которой оно
сигнализирует, вложение не принимается.

Открытый вопрос: подходит ли для голосовых канал VIDEO_UPLOAD с type=2, uploader_type=1?

Это именно вопрос, а не утверждение — но несколько деталей в исходниках указывают в одну сторону.

Сервер ответил, что не поддерживается video, хотя в _type было передано AUDIO. Это
согласуется с тем, что реально уходит на провод: это payload видео-вложения, в котором заменён
только дискриминатор. to_payload() сериализует с by_alias=True (pymax/api/models.py), поэтому
отправляется:

{"_type": "AUDIO", "videoId": ..., "token": ..., "videoType": 0}

Ещё две детали из исходников:

  • В VideoAttachPayload объявлены поля duration и wave (pymax/api/uploads/payloads.py:19),
    причём wave нигде в пакете не заполняется. Поле waveform в исходящей модели вложения,
    соответствующее входящему AudioAttachment.wave, наводит на мысль, что голосовое вложение должно
    нести длительность и данные waveform, которых upload_voice не отправляет.
  • VideoNote — вторая загрузка с uploader_type=1 — вообще не ждёт уведомления. Она читает
    тело POST-ответа как JSON, достаёт оттуда thumbhash и сразу возвращается с video_type=1 и
    длительностью (pymax/api/uploads/service.py:421-436). У upload_voice тот же
    uploader_type=1, отличается только type (2 против 1), но при этом она идёт по пути с
    уведомлением и полностью выбрасывает тело POST-ответа. Если серверная сторона обрабатывает
    голосовое так же, как видеосообщение, то нужные для вложения данные, возможно, уже лежат в теле
    ответа.

Ещё одно наблюдение, чисто как предположение: opcode 85 отсутствует в pymax.protocol.Opcode
84 это VIDEO_CHAT_CREATE_JOIN_LINK, 86 это CHAT_PIN_SET_VISIBILITY, а 80/81/82/83/87/88/89 все
заняты. Он находится ровно в середине блока opcode'ов, отвечающих за загрузки. Никаких доказательств
того, что он относится к аудио, у нас нет.

Находка 3 — даже пришедшее уведомление, скорее всего, не нашло бы свой waiter

Это вывод из исходников, а не наблюдение — мы ни разу не видели голосовой кадр NOTIF_ATTACH,
который можно было бы отследить. Стоит отдельного упоминания, потому что проблема проявится сразу
после того, как будет починена Находка 1.

а) Ключ регистрации и ключ поиска — разные поля.
upload_voice регистрирует waiter по video_id из VideoUploadResponse.info[0].video_id
(pymax/api/uploads/service.py:248-251):

video_id = upload_info.video_id
self.voice_upload_waiters[video_id] = future

А on_voice_attach ищет по attach.audio_id (pymax/api/uploads/service.py:596):

future = self.voice_upload_waiters.pop(attach.audio_id, None)

Если сервер не переиспользует одно и то же число для обоих, поиск не найдёт ничего, обработчик
запишет в лог No voice upload waiter found и молча выйдет.

б) Перебор моделей по порядку может отнести кадр не к тому типу ещё до on_voice_attach.
resolve_attach (pymax/dispatch/resolvers.py:39-56) классифицирует NOTIF_ATTACH, пробуя
провалидировать модели в фиксированном порядке:

FileUploadSignal  -> FILE_READY    # требуется только fileId
VideoUploadSignal -> VIDEO_READY   # требуется только videoId
AudioUploadSignal -> VOICE_READY   # требуется только audioId

Все три наследуются от CamelModel с extra="allow" (pymax/types/domain/base.py), поэтому
побеждает первая модель, у которой присутствует её единственное обязательное поле, а все остальные
поля просто игнорируются. Если голосовой NOTIF_ATTACH несёт videoId — что вполне вероятно, ведь
загрузка запрашивалась через VIDEO_UPLOAD, а ответ разбирался как VideoUploadResponse, — то он
первым делом подойдёт под VideoUploadSignal, уйдёт в on_video_attach, ничего не найдёт в
video_upload_waiters и будет молча отброшен. Payload, содержащий только audioId,
классифицировался бы правильно; какой из вариантов на самом деле — мы не знаем.

Предложение: различать типы по явному полю типа (или по метаданным самого кадра) надёжнее, чем
перебором с валидацией по порядку. Та же ловушка сработает и для любого нового типа сигнала, который
добавят позже: сегодня любой payload, содержащий fileId, будет распознан как FILE_READY
независимо от того, чем он является на самом деле.


Что мы уже исключили

  • Корректность данных — сигнатура OggS проверена на тех самых байтах, которые передаются в
    Voice; это обычные голосовые Telegram в Opus/Ogg, которые нормально воспроизводятся везде.
  • Сама HTTP-загрузка — POST возвращает 200 меньше чем за секунду, никаких ошибок aiohttp не
    возникает.
  • Размер — 29 КБ и 57 КБ. Это не проблема чанкинга и не таймаут во время передачи.
  • Нестабильность / задержка серверной обработки — воспроизводится каждый раз, а окно в 60 секунд
    на три порядка больше, чем ~1 секунда, за которую отрабатывает загрузка файла.
  • Состояние соединения — во время 60-секундного ожидания продолжают идти кадры CHAT_HISTORY и
    PING, а загрузка File по тому же соединению минутой позже проходит штатно.

Что помогло бы

  1. Подтверждение, присылает ли MAX NOTIF_ATTACH для голосовых вообще, и если да — то по какому
    opcode и через какой канал загрузки.
  2. Если не присылает — что upload_voice должна делать вместо ожидания: вероятно, читать тело
    POST-ответа так же, как это делает VideoNote, и заполнять duration/wave в payload вложения.
  3. Независимо от предыдущих пунктов — регистрировать waiter и искать его по одному и тому же полю
    (Находка 3а).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions