Skip to content

fix(aiocqhttp): resolve bare record file references#9219

Open
Last-emo-boy wants to merge 2 commits into
AstrBotDevs:masterfrom
Last-emo-boy:fix/9199-aiocqhttp-record-source
Open

fix(aiocqhttp): resolve bare record file references#9219
Last-emo-boy wants to merge 2 commits into
AstrBotDevs:masterfrom
Last-emo-boy:fix/9199-aiocqhttp-record-source

Conversation

@Last-emo-boy

@Last-emo-boy Last-emo-boy commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #9199.

OneBot v11 allows an incoming record.data.file value to be an
implementation-owned voice filename rather than a directly accessible path or
URL. The aiocqhttp adapter previously passed that value directly to Record,
leaving downstream media resolution and STT with an unusable reference.

This change follows the official OneBot v11
get_record
contract.

Modifications / 改动点

  • Resolve file-only incoming record segments through get_record, requesting
    WAV output for the downstream audio pipeline.

  • Preserve existing behavior when a record already has a URL, path, URI, local
    file, or supported base64 reference.

  • Preserve the original record segment when get_record fails or returns a
    path that AstrBot cannot access.

  • Keep the raw OneBot event unchanged and retain self_id routing for
    multi-account setups.

  • Add regression tests for successful resolution, direct-source bypasses,
    legacy base64, account routing, and fallback behavior.

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

python -m pytest -q \
  tests/unit/test_aiocqhttp_record.py \
  tests/unit/test_aiocqhttp_reply.py \
  tests/unit/test_aiocqhttp_poke.py \
  tests/test_platform_audio_media_resolver.py \
  tests/test_mimo_api_sources.py \
  tests/test_media_utils.py

91 passed
python -m ruff format --check .
480 files already formatted
python -m ruff check .
All checks passed!
git diff --check
Passed

Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Resolve OneBot v11 record segments that contain only implementation-owned file identifiers by integrating the get_record API in the aiocqhttp adapter while preserving existing behavior and fallbacks.

Bug Fixes:

  • Ensure incoming OneBot record segments with bare file references are converted into accessible audio sources via get_record for downstream media resolution and STT.
  • Avoid unnecessary get_record calls when a record already has a usable URL, path, local file, file URI, or recognizable base64-encoded audio payload.
  • Preserve the original record segment when get_record fails or returns an inaccessible file path so events and routing remain stable.

Enhancements:

  • Trim and normalize record segment fields before constructing components to improve robustness of media handling.
  • Add signature-based detection for legacy bare base64 audio payloads to keep them compatible without additional OneBot calls.

Tests:

  • Add unit tests covering record resolution through get_record, handling of legacy and signature-based bare base64 audio, bypass behavior for direct sources, failure and inaccessible-path fallbacks, and correct account routing via self_id.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a mechanism to resolve file-only OneBot record segments via the get_record action when direct media sources are not present, accompanied by comprehensive unit tests. The review feedback is highly constructive and suggests refining the legacy base64 detection by checking for audio magic headers to avoid false positives on hex-like filenames, catching ValueError alongside OSError during path checks to ensure Windows compatibility, and adding a corresponding unit test for the hex-like filename scenario.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +180 to +188
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Detecting legacy bare base64 payloads solely by attempting to decode them can lead to false positives. For example, a bare filename that is a hex string (such as an MD5 hash like 0d2bb1468a87d64414f8e563cc61c33c or a UUID) contains only valid base64 characters and has a length divisible by 4. It will successfully decode, causing has_direct_source to be incorrectly set to True, which bypasses the get_record call entirely.

To prevent this, we should verify that the decoded bytes actually start with a known audio magic header (matching the formats supported by _get_audio_magic_type in media_utils.py).

        if not has_direct_source:
            compact = "".join(file_ref.split())
            padding = len(compact) % 4
            if padding:
                compact += "=" * (4 - padding)
            try:
                decoded = base64.b64decode(compact, validate=True)
                has_direct_source = (
                    decoded.startswith(b"RIFF")
                    or decoded.startswith(b"#!AM")
                    or decoded.startswith(b"OggS")
                    or decoded.startswith(b"fLa")
                    or decoded.startswith(b"ID3")
                    or decoded.startswith(bytes([255, 251]))
                    or decoded.startswith(bytes([255, 243]))
                    or decoded.startswith(bytes([255, 242]))
                    or decoded.startswith(b"ftyp")
                    or decoded.startswith(b"#!SILK_V3")
                    or decoded.startswith(bytes([2]) + b"#!SILK_V3")
                )
            except (binascii.Error, ValueError):
                pass

Comment on lines +176 to +179
try:
has_direct_source = has_direct_source or Path(file_ref).is_file()
except OSError:
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

On Windows, passing a string with invalid path characters (such as colons in URLs like http://... or base64://...) to Path().is_file() can raise a ValueError rather than an OSError. Catching both OSError and ValueError ensures robust handling across all platforms.

Suggested change
try:
has_direct_source = has_direct_source or Path(file_ref).is_file()
except OSError:
pass
try:
has_direct_source = has_direct_source or Path(file_ref).is_file()
except (OSError, ValueError):
pass

Comment on lines +216 to +221
try:
if Path(file_uri_to_path(converted_file)).is_file():
normalized["file"] = converted_file
return normalized
except OSError:
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similarly, catching ValueError in addition to OSError here prevents potential unhandled exceptions on Windows if converted_file contains invalid path characters.

Suggested change
try:
if Path(file_uri_to_path(converted_file)).is_file():
normalized["file"] = converted_file
return normalized
except OSError:
pass
try:
if Path(file_uri_to_path(converted_file)).is_file():
normalized["file"] = converted_file
return normalized
except (OSError, ValueError):
pass

record = message.message[0]
assert isinstance(record, Record)
assert record.file == "voice.amr"
bot.call_action.assert_not_awaited()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It would be beneficial to add a test case to verify that a bare filename consisting of valid base64 characters (such as a hex hash or UUID) is correctly resolved through get_record rather than being bypassed as a legacy base64 payload.

    bot.call_action.assert_not_awaited()


@pytest.mark.asyncio
async def test_record_with_hex_filename_is_resolved_through_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(bytes([0, 0]))
    bot = AsyncMock()
    bot.call_action.return_value = {"file": str(converted_file)}
    event = _record_event({"file": "0d2bb1468a87d64414f8e563cc61c33c"})

    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)
    bot.call_action.assert_awaited_once_with(
        action="get_record",
        file="0d2bb1468a87d64414f8e563cc61c33c",
        out_format="wav",
        self_id=300,
    )
References
  1. New functionality, such as handling attachments, should be accompanied by corresponding unit tests.

@Last-emo-boy Last-emo-boy marked this pull request as ready for review July 12, 2026 10:51
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. area:platform The bug / feature is about IM platform adapter, such as QQ, Lark, Telegram, WebChat and so on. labels Jul 12, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The _resolve_record_data method has grown quite large and mixes several concerns (normalization, source detection, base64 signature parsing, and OneBot RPC); consider breaking it into smaller helpers (e.g., has_direct_source, is_supported_bare_base64) to improve readability and make the heuristics easier to reuse or adjust.
  • The bare base64 audio detection logic (signature checks and manual padding) is fairly specialized; if there are existing utilities in media_utils or elsewhere for identifying/normalizing audio blobs, it would be beneficial to reuse or centralize this logic to avoid divergence and reduce maintenance cost.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `_resolve_record_data` method has grown quite large and mixes several concerns (normalization, source detection, base64 signature parsing, and OneBot RPC); consider breaking it into smaller helpers (e.g., `has_direct_source`, `is_supported_bare_base64`) to improve readability and make the heuristics easier to reuse or adjust.
- The bare base64 audio detection logic (signature checks and manual padding) is fairly specialized; if there are existing utilities in `media_utils` or elsewhere for identifying/normalizing audio blobs, it would be beneficial to reuse or centralize this logic to avoid divergence and reduce maintenance cost.

## Individual Comments

### Comment 1
<location path="astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py" line_range="147" />
<code_context>

         return abm

+    async def _resolve_record_data(
+        self,
+        data: dict[str, Any],
</code_context>
<issue_to_address>
**issue (complexity):** Consider splitting `_resolve_record_data` into smaller helpers for normalization, media-source detection, base64-audio sniffing, and `get_record` handling to keep the main flow linear and readable.

You can keep all current behavior but reduce complexity by extracting the clearly independent concerns into small helpers. This will also make it easier to test each piece.

### 1. Extract normalization and direct-source detection

Move the normalization and “has direct source” logic into helpers to clarify the high‑level flow:

```python
def _normalize_record_data(self, data: dict[str, Any]) -> dict[str, Any]:
    normalized = dict(data)
    for key in ("file", "url", "path"):
        if isinstance(normalized.get(key), str):
            normalized[key] = normalized[key].strip()
    return normalized

def _has_direct_media_source(self, normalized: dict[str, Any]) -> bool:
    file_ref = normalized.get("file")
    if not isinstance(file_ref, str) or not file_ref:
        return False

    if normalized.get("url") or normalized.get("path"):
        return True
    if is_file_uri(file_ref) or file_ref.startswith(("http://", "https://", "base64://", "data:")):
        return True

    try:
        if Path(file_ref).is_file():
            return True
    except (OSError, ValueError):
        pass

    return self._is_probable_base64_audio(file_ref)
```

Then `_resolve_record_data` becomes more linear and easier to read:

```python
async def _resolve_record_data(
    self,
    data: dict[str, Any],
    routing_params: dict[str, Any],
) -> dict[str, Any]:
    normalized = self._normalize_record_data(data)
    file_ref = normalized.get("file")
    if not isinstance(file_ref, str) or not file_ref:
        return normalized

    if self._has_direct_media_source(normalized):
        return normalized

    record = await self._resolve_record_via_get_record(file_ref, routing_params)
    if record is not None:
        return record
    return normalized
```

### 2. Extract base64 audio detection

The inline “mini format sniffer” can be encapsulated into a single helper. That keeps `_has_direct_media_source` readable and prevents further format heuristics from bloating `_resolve_record_data`:

```python
def _is_probable_base64_audio(self, file_ref: str) -> bool:
    compact = "".join(file_ref.split())
    padding = len(compact) % 4
    if padding:
        compact += "=" * (4 - padding)

    try:
        decoded = base64.b64decode(compact, validate=True)
    except (binascii.Error, ValueError):
        return False

    if (
        len(decoded) >= 12
        and decoded[:4] == b"RIFF"
        and decoded[8:12] == b"WAVE"
    ):
        return True
    if len(decoded) >= 12 and decoded[4:8] == b"ftyp":
        return True
    return 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",
        )
    )
```

If you already have or can add `media_utils`, this helper can be moved there, and `_has_direct_media_source` can call something like `is_base64_audio(file_ref)` instead.

### 3. Extract the `get_record` call / post-processing

The call to `self.bot.call_action` and its result handling can be factored out, isolating routing/bot coupling:

```python
async def _resolve_record_via_get_record(
    self,
    file_ref: str,
    routing_params: dict[str, Any],
) -> dict[str, Any] | None:
    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 None

    if not isinstance(result, dict):
        logger.warning(
            "OneBot get_record returned no file path; preserving the original record segment."
        )
        return None

    converted_file = result.get("file")
    if not isinstance(converted_file, str) or not converted_file.strip():
        logger.warning(
            "OneBot get_record returned no accessible media source; "
            "preserving the original record segment."
        )
        return None

    converted_file = converted_file.strip()
    try:
        if Path(file_uri_to_path(converted_file)).is_file():
            return {"file": converted_file}
    except (OSError, ValueError):
        pass

    logger.warning(
        "OneBot get_record returned no accessible media source; "
        "preserving the original record segment."
    )
    return None
```

This preserves all existing behavior (including logging and fallbacks) while making `_resolve_record_data` a thin orchestrator, which addresses the complexity concerns without reverting your feature.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.


return abm

async def _resolve_record_data(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider splitting _resolve_record_data into smaller helpers for normalization, media-source detection, base64-audio sniffing, and get_record handling to keep the main flow linear and readable.

You can keep all current behavior but reduce complexity by extracting the clearly independent concerns into small helpers. This will also make it easier to test each piece.

1. Extract normalization and direct-source detection

Move the normalization and “has direct source” logic into helpers to clarify the high‑level flow:

def _normalize_record_data(self, data: dict[str, Any]) -> dict[str, Any]:
    normalized = dict(data)
    for key in ("file", "url", "path"):
        if isinstance(normalized.get(key), str):
            normalized[key] = normalized[key].strip()
    return normalized

def _has_direct_media_source(self, normalized: dict[str, Any]) -> bool:
    file_ref = normalized.get("file")
    if not isinstance(file_ref, str) or not file_ref:
        return False

    if normalized.get("url") or normalized.get("path"):
        return True
    if is_file_uri(file_ref) or file_ref.startswith(("http://", "https://", "base64://", "data:")):
        return True

    try:
        if Path(file_ref).is_file():
            return True
    except (OSError, ValueError):
        pass

    return self._is_probable_base64_audio(file_ref)

Then _resolve_record_data becomes more linear and easier to read:

async def _resolve_record_data(
    self,
    data: dict[str, Any],
    routing_params: dict[str, Any],
) -> dict[str, Any]:
    normalized = self._normalize_record_data(data)
    file_ref = normalized.get("file")
    if not isinstance(file_ref, str) or not file_ref:
        return normalized

    if self._has_direct_media_source(normalized):
        return normalized

    record = await self._resolve_record_via_get_record(file_ref, routing_params)
    if record is not None:
        return record
    return normalized

2. Extract base64 audio detection

The inline “mini format sniffer” can be encapsulated into a single helper. That keeps _has_direct_media_source readable and prevents further format heuristics from bloating _resolve_record_data:

def _is_probable_base64_audio(self, file_ref: str) -> bool:
    compact = "".join(file_ref.split())
    padding = len(compact) % 4
    if padding:
        compact += "=" * (4 - padding)

    try:
        decoded = base64.b64decode(compact, validate=True)
    except (binascii.Error, ValueError):
        return False

    if (
        len(decoded) >= 12
        and decoded[:4] == b"RIFF"
        and decoded[8:12] == b"WAVE"
    ):
        return True
    if len(decoded) >= 12 and decoded[4:8] == b"ftyp":
        return True
    return 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",
        )
    )

If you already have or can add media_utils, this helper can be moved there, and _has_direct_media_source can call something like is_base64_audio(file_ref) instead.

3. Extract the get_record call / post-processing

The call to self.bot.call_action and its result handling can be factored out, isolating routing/bot coupling:

async def _resolve_record_via_get_record(
    self,
    file_ref: str,
    routing_params: dict[str, Any],
) -> dict[str, Any] | None:
    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 None

    if not isinstance(result, dict):
        logger.warning(
            "OneBot get_record returned no file path; preserving the original record segment."
        )
        return None

    converted_file = result.get("file")
    if not isinstance(converted_file, str) or not converted_file.strip():
        logger.warning(
            "OneBot get_record returned no accessible media source; "
            "preserving the original record segment."
        )
        return None

    converted_file = converted_file.strip()
    try:
        if Path(file_uri_to_path(converted_file)).is_file():
            return {"file": converted_file}
    except (OSError, ValueError):
        pass

    logger.warning(
        "OneBot get_record returned no accessible media source; "
        "preserving the original record segment."
    )
    return None

This preserves all existing behavior (including logging and fallbacks) while making _resolve_record_data a thin orchestrator, which addresses the complexity concerns without reverting your feature.

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

Labels

area:platform The bug / feature is about IM platform adapter, such as QQ, Lark, Telegram, WebChat and so on. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]aiocqhttp 适配器未处理 record 消息的裸文件名,导致语音转文字失败

1 participant