Кратко
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 по тому же соединению минутой позже проходит штатно.
Что помогло бы
- Подтверждение, присылает ли MAX
NOTIF_ATTACH для голосовых вообще, и если да — то по какому
opcode и через какой канал загрузки.
- Если не присылает — что
upload_voice должна делать вместо ожидания: вероятно, читать тело
POST-ответа так же, как это делает VideoNote, и заполнять duration/wave в payload вложения.
- Независимо от предыдущих пунктов — регистрировать waiter и искать его по одному и тому же полю
(Находка 3а).
Кратко
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-pythonapi.oneme.ruExtraConfig(log_level="DEBUG")Воспроизведение
Ожидается: сообщение уходит, в чате появляется голосовой пузырь.
По факту:
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):Загрузка
Fileминутой позже, в том же запуске и по тому же соединению, отрабатывает ровно так,как задумано:
За весь запуск opcode 136 пришёл ровно один раз — для файла. После голосового POST не пришло
вообще ни одного кадра.
Находка 2 — если пропустить ожидание, отправку отклоняет сервер
Мы проверили гипотезу, что ожидание ничего не даёт:
upload_voiceотбрасывает значение future исобирает возвращаемый payload целиком из более раннего ответа на
VIDEO_UPLOAD(
pymax/api/uploads/service.py:298):Если подменить
uploads.voice_upload_waitersна словарь, который завершает future прямо в моментрегистрации, загрузка «успешно» проходит мгновенно. После этого
MSG_SENDотклоняется:Значит, уведомление действительно необходимо: без той серверной обработки, о которой оно
сигнализирует, вложение не принимается.
Открытый вопрос: подходит ли для голосовых канал
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):А
on_voice_attachищет поattach.audio_id(pymax/api/uploads/service.py:596):Если сервер не переиспользует одно и то же число для обоих, поиск не найдёт ничего, обработчик
запишет в лог
No voice upload waiter foundи молча выйдет.б) Перебор моделей по порядку может отнести кадр не к тому типу ещё до
on_voice_attach.resolve_attach(pymax/dispatch/resolvers.py:39-56) классифицируетNOTIF_ATTACH, пробуяпровалидировать модели в фиксированном порядке:
Все три наследуются от
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, которые нормально воспроизводятся везде.aiohttpневозникает.
на три порядка больше, чем ~1 секунда, за которую отрабатывает загрузка файла.
CHAT_HISTORYиPING, а загрузкаFileпо тому же соединению минутой позже проходит штатно.Что помогло бы
NOTIF_ATTACHдля голосовых вообще, и если да — то по какомуopcode и через какой канал загрузки.
upload_voiceдолжна делать вместо ожидания: вероятно, читать телоPOST-ответа так же, как это делает
VideoNote, и заполнятьduration/waveв payload вложения.(Находка 3а).