From 5b45ec91b6876b75d6e618831de40a56b784b7bf Mon Sep 17 00:00:00 2001 From: w33d Date: Sun, 12 Jul 2026 16:30:16 +0800 Subject: [PATCH 1/2] fix(aiocqhttp): resolve bare record file references --- .../aiocqhttp/aiocqhttp_platform_adapter.py | 93 +++++++++- tests/unit/test_aiocqhttp_record.py | 165 ++++++++++++++++++ 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_aiocqhttp_record.py diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index a69cb915f5..c8aa7226b3 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -1,10 +1,13 @@ import asyncio +import base64 +import binascii import inspect import itertools import logging import time import uuid from collections.abc import Awaitable +from pathlib import Path from typing import Any, cast from aiocqhttp import CQHttp, Event @@ -21,6 +24,7 @@ PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.media_utils import file_uri_to_path, is_file_uri from ...register import register_platform_adapter from .aiocqhttp_message_event import * @@ -140,6 +144,88 @@ async def convert_message(self, event: Event) -> AstrBotMessage | None: return abm + async def _resolve_record_data( + self, + data: dict[str, Any], + routing_params: dict[str, Any], + ) -> dict[str, Any]: + """Resolve a file-only OneBot record segment through ``get_record``. + + Args: + data: Raw record segment data from the OneBot event. + routing_params: Account routing parameters for the OneBot action. + + Returns: + A copy of the segment data with an accessible media source when the + OneBot implementation can provide one. Otherwise, the original data. + """ + normalized = dict(data) + for key in ("file", "url", "path"): + if isinstance(normalized.get(key), str): + normalized[key] = normalized[key].strip() + file_ref = normalized.get("file") + if not isinstance(file_ref, str) or not file_ref: + return normalized + + has_direct_source = bool( + normalized.get("url") or normalized.get("path") + ) or is_file_uri(file_ref) + has_direct_source = has_direct_source or file_ref.startswith( + ("http://", "https://", "base64://", "data:") + ) + try: + has_direct_source = has_direct_source or Path(file_ref).is_file() + except OSError: + pass + if not has_direct_source: + compact = "".join(file_ref.split()) + padding = len(compact) % 4 + if padding: + compact += "=" * (4 - padding) + try: + has_direct_source = bool(base64.b64decode(compact, validate=True)) + except (binascii.Error, ValueError): + pass + if has_direct_source: + return normalized + + try: + result = await self.bot.call_action( + action="get_record", + file=file_ref, + out_format="wav", + **routing_params, + ) + except Exception as exc: + logger.warning( + "OneBot get_record failed; preserving the original record segment: %s", + exc, + ) + return normalized + + if not isinstance(result, dict): + logger.warning( + "OneBot get_record returned no file path; preserving the original " + "record segment." + ) + return normalized + + converted_file = result.get("file") + if isinstance(converted_file, str) and converted_file.strip(): + converted_file = converted_file.strip() + try: + if Path(file_uri_to_path(converted_file)).is_file(): + normalized["file"] = converted_file + return normalized + except OSError: + pass + + logger.warning( + "OneBot get_record returned no accessible media source; " + "preserving the original record segment." + ) + return normalized + async def _convert_handle_request_event(self, event: Event) -> AstrBotMessage: """OneBot V11 请求类事件""" abm = AstrBotMessage() @@ -410,7 +496,12 @@ async def _convert_handle_message_event( f"不支持的消息段类型,已忽略: {t}, data={m['data']}" ) continue - a = ComponentTypes[t](**m["data"]) + component_data = m["data"] + if t == "record": + component_data = await self._resolve_record_data( + component_data, routing_params + ) + a = ComponentTypes[t](**component_data) abm.message.append(a) except Exception as e: logger.exception( diff --git a/tests/unit/test_aiocqhttp_record.py b/tests/unit/test_aiocqhttp_record.py new file mode 100644 index 0000000000..4d46126c3c --- /dev/null +++ b/tests/unit/test_aiocqhttp_record.py @@ -0,0 +1,165 @@ +import base64 +import io +import wave +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from aiocqhttp import Event + +from astrbot.core.message.components import Record +from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import ( + AiocqhttpAdapter, +) + + +def _record_event(data: dict) -> Event: + event = Event.from_payload( + { + "post_type": "message", + "message_type": "private", + "sub_type": "friend", + "message_id": 100, + "user_id": 200, + "self_id": 300, + "sender": {"user_id": 200, "nickname": "tester"}, + "message": [{"type": "record", "data": data}], + "raw_message": "", + "time": 1, + "font": 0, + } + ) + assert event is not None + return event + + +def _adapter(bot: AsyncMock) -> AiocqhttpAdapter: + adapter = AiocqhttpAdapter.__new__(AiocqhttpAdapter) + adapter.bot = bot + return adapter + + +@pytest.mark.asyncio +async def test_bare_record_file_is_resolved_through_onebot_get_record(tmp_path): + converted_file = tmp_path / "voice.wav" + with wave.open(str(converted_file), "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(8000) + wav_file.writeframes(b"\x00\x00") + bot = AsyncMock() + bot.call_action.return_value = {"file": str(converted_file)} + event = _record_event({"file": "voice.amr"}) + + message = await _adapter(bot)._convert_handle_message_event(event) + + assert len(message.message) == 1 + record = message.message[0] + assert isinstance(record, Record) + assert record.file == str(converted_file) + assert await record.convert_to_file_path() == str(converted_file.resolve()) + assert event.message[0]["data"]["file"] == "voice.amr" + bot.call_action.assert_awaited_once_with( + action="get_record", + file="voice.amr", + out_format="wav", + self_id=300, + ) + + +@pytest.mark.asyncio +async def test_record_with_legacy_bare_base64_does_not_call_get_record(): + audio_buffer = io.BytesIO() + with wave.open(audio_buffer, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(8000) + wav_file.writeframes(b"\x00\x00") + encoded_audio = base64.b64encode(audio_buffer.getvalue()).decode() + bot = AsyncMock() + + message = await _adapter(bot)._convert_handle_message_event( + _record_event({"file": encoded_audio}) + ) + + record = message.message[0] + assert isinstance(record, Record) + assert record.file == encoded_audio + resolved_path = Path(await record.convert_to_file_path()) + try: + resolved_bytes = resolved_path.read_bytes() + assert resolved_bytes[:4] == b"RIFF" + assert resolved_bytes[8:12] == b"WAVE" + finally: + resolved_path.unlink(missing_ok=True) + bot.call_action.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_record_with_url_does_not_call_get_record(): + bot = AsyncMock() + data = { + "file": " voice.amr ", + "url": " https://example.test/voice.amr ", + } + + message = await _adapter(bot)._convert_handle_message_event(_record_event(data)) + + record = message.message[0] + assert isinstance(record, Record) + assert record.file == "voice.amr" + assert record.url == "https://example.test/voice.amr" + assert data["file"] == " voice.amr " + assert data["url"] == " https://example.test/voice.amr " + bot.call_action.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_get_record_failure_preserves_original_record_segment(): + bot = AsyncMock() + bot.call_action.side_effect = RuntimeError("get_record unavailable") + + message = await _adapter(bot)._convert_handle_message_event( + _record_event({"file": "voice.amr"}) + ) + + assert len(message.message) == 1 + record = message.message[0] + assert isinstance(record, Record) + assert record.file == "voice.amr" + bot.call_action.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_inaccessible_get_record_path_preserves_original_record_segment(): + bot = AsyncMock() + bot.call_action.return_value = {"file": "/remote/voice.wav"} + + message = await _adapter(bot)._convert_handle_message_event( + _record_event({"file": "voice.amr"}) + ) + + record = message.message[0] + assert isinstance(record, Record) + assert record.file == "voice.amr" + bot.call_action.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source_field", ["path", "url"]) +async def test_record_with_existing_source_field_does_not_call_get_record(source_field): + bot = AsyncMock() + + message = await _adapter(bot)._convert_handle_message_event( + _record_event( + { + "file": "voice.amr", + source_field: "/remote/voice.amr", + } + ) + ) + + record = message.message[0] + assert isinstance(record, Record) + assert record.file == "voice.amr" + bot.call_action.assert_not_awaited() From 9883359c6e4cec44034b5355cccd3d43b72aea9d Mon Sep 17 00:00:00 2001 From: w33d Date: Sun, 12 Jul 2026 18:42:58 +0800 Subject: [PATCH 2/2] fix(aiocqhttp): validate bare base64 audio --- .../aiocqhttp/aiocqhttp_platform_adapter.py | 31 +++++++++-- tests/unit/test_aiocqhttp_record.py | 54 +++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index c8aa7226b3..02efa1d6d8 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -175,7 +175,7 @@ async def _resolve_record_data( ) try: has_direct_source = has_direct_source or Path(file_ref).is_file() - except OSError: + except (OSError, ValueError): pass if not has_direct_source: compact = "".join(file_ref.split()) @@ -183,7 +183,32 @@ async def _resolve_record_data( if padding: compact += "=" * (4 - padding) try: - has_direct_source = bool(base64.b64decode(compact, validate=True)) + decoded = base64.b64decode(compact, validate=True) + has_direct_source = ( + ( + len(decoded) >= 12 + and decoded[:4] == b"RIFF" + and decoded[8:12] == b"WAVE" + ) + or (len(decoded) >= 12 and decoded[4:8] == b"ftyp") + or decoded.startswith( + ( + b"#!AMR\n", + b"#!AMR-WB\n", + b"OggS", + b"fLaC", + b"ID3", + b"\xff\xfb", + b"\xff\xfa", + b"\xff\xf3", + b"\xff\xf2", + b"\xff\xe3", + b"\xff\xe2", + b"#!SILK_V3", + b"\x02#!SILK_V3", + ) + ) + ) except (binascii.Error, ValueError): pass if has_direct_source: @@ -217,7 +242,7 @@ async def _resolve_record_data( if Path(file_uri_to_path(converted_file)).is_file(): normalized["file"] = converted_file return normalized - except OSError: + except (OSError, ValueError): pass logger.warning( diff --git a/tests/unit/test_aiocqhttp_record.py b/tests/unit/test_aiocqhttp_record.py index 4d46126c3c..c9d28ba403 100644 --- a/tests/unit/test_aiocqhttp_record.py +++ b/tests/unit/test_aiocqhttp_record.py @@ -95,6 +95,60 @@ async def test_record_with_legacy_bare_base64_does_not_call_get_record(): bot.call_action.assert_not_awaited() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "audio_bytes", + [ + b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00M4A isom", + b"\xff\xf3\x90\x00", + b"\xff\xf2\x90\x00", + ], +) +async def test_record_with_supported_bare_base64_signature_skips_get_record( + audio_bytes, +): + encoded_audio = base64.b64encode(audio_bytes).decode() + bot = AsyncMock() + + message = await _adapter(bot)._convert_handle_message_event( + _record_event({"file": encoded_audio}) + ) + + record = message.message[0] + assert isinstance(record, Record) + assert record.file == encoded_audio + bot.call_action.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "file_ref", + [ + "0d2bb1468a87d64414f8e563cc61c33c", + "550e8400e29b41d4a716446655440000", + ], +) +async def test_hash_or_uuid_record_file_is_resolved_through_onebot_get_record( + file_ref, +): + bot = AsyncMock() + bot.call_action.return_value = {} + + message = await _adapter(bot)._convert_handle_message_event( + _record_event({"file": file_ref}) + ) + + record = message.message[0] + assert isinstance(record, Record) + assert record.file == file_ref + bot.call_action.assert_awaited_once_with( + action="get_record", + file=file_ref, + out_format="wav", + self_id=300, + ) + + @pytest.mark.asyncio async def test_record_with_url_does_not_call_get_record(): bot = AsyncMock()