Skip to content

feat(aiocqhttp): 优化文件上传处理逻辑#8466

Open
NayukiChiba wants to merge 2 commits into
AstrBotDevs:masterfrom
NayukiChiba:fix/8464-qq-file-send
Open

feat(aiocqhttp): 优化文件上传处理逻辑#8466
NayukiChiba wants to merge 2 commits into
AstrBotDevs:masterfrom
NayukiChiba:fix/8464-qq-file-send

Conversation

@NayukiChiba
Copy link
Copy Markdown
Contributor

@NayukiChiba NayukiChiba commented May 31, 2026

Modifications / 改动点

  • 优先使用 NapCat 的 upload API 上传文件,上传成功后自动发送
  • 增加异常处理,上传失败时回退到直接发送
  • 提升文件发送的可靠性和用户体验

close #8464

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

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

platform win32 -- Python 3.12.12, pytest-9.0.3, pluggy-1.6.0
rootdir: D:\Nayey\Code\NayukiChiba\Astrbot
configfile: pyproject.toml
plugins: anyio-4.13.0, asyncio-1.4.0, cov-7.1.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 7 items                                                                                   
tests\unit\test_aiocqhttp_file_message.py .......                                              [100%]
7 passed in 2.27s 

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

Route aiocqhttp file message segments through NapCat upload APIs when possible and add fallbacks for robust file delivery.

New Features:

  • Support uploading local files via upload_group_file and upload_private_file APIs before sending messages.

Bug Fixes:

  • Ensure file messages still send via the original dispatch path when upload APIs fail or cannot be used.

Tests:

  • Add unit tests covering upload API usage, mixed content messages, failure fallbacks, missing session IDs, and multiple file segments.

- 优先使用 NapCat 的 upload API 上传文件,上传成功后自动发送
- 增加异常处理,上传失败时回退到直接发送
- 提升文件发送的可靠性和用户体验
@dosubot dosubot Bot added size:S This PR changes 10-29 lines, ignoring generated files. area:platform The bug / feature is about IM platform adapter, such as QQ, Lark, Telegram, WebChat and so on. labels May 31, 2026
Copy link
Copy Markdown
Contributor

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

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 feature to prioritize uploading files using NapCat's upload_group_file and upload_private_file APIs when sending file segments in the aiocqhttp platform, falling back to direct sending if the upload fails. It also adds comprehensive unit tests to cover these scenarios. The feedback suggests checking if the file exists locally and converting it to an absolute path before uploading, as well as downgrading the error log to a warning during fallback to prevent log spam on unsupported clients.

Comment on lines +160 to +182
file_path = seg.file
if file_path and session_id and session_id.isdigit():
sid = int(session_id)
try:
if is_group:
await bot.call_action(
"upload_group_file",
group_id=sid,
file=file_path,
name=seg.name or "file",
)
else:
await bot.call_action(
"upload_private_file",
user_id=sid,
file=file_path,
name=seg.name or "file",
)
continue # 上传成功,upload API 自带发送功能
except Exception as e:
from astrbot.api import logger

logger.error(f"上传文件失败,回退到直接发送: {e}")
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

这里有几点可以优化:

  1. 路径存在性与绝对路径转换

    • 建议在调用 upload_group_file / upload_private_file 之前,先使用 os.path.exists(file_path) 检查文件是否在本地存在。这样可以避免对网络 URL 或不存在的文件路径进行无意义的上传 API 调用。
    • 建议使用 os.path.abspath(file_path) 将文件路径转换为绝对路径。因为 OneBot 客户端(如 NapCat)可能运行在不同的工作目录下,使用相对路径可能会导致其无法正确找到并上传文件。
  2. 日志级别调整

    • 当前在上传失败回退到直接发送时,使用了 logger.error。由于对于不支持这些自定义上传 API 的 OneBot 客户端,每次发送文件都会触发此回退逻辑,使用 logger.error 会导致日志中充斥大量的“错误”信息。建议将其降级为 logger.warning
  3. 重构与测试(根据项目规范)

    • 避免代码重复:在实现类似功能(如直接发送与引用发送附件)时,建议将逻辑重构为共享的辅助函数(helper function),以避免代码重复。
    • 单元测试:新增的附件处理功能应当伴随相应的单元测试,以确保其稳定性和正确性。
file_path = seg.file
import os
if file_path and os.path.exists(file_path) and session_id and session_id.isdigit():
    file_path = os.path.abspath(file_path)
    sid = int(session_id)
    try:
        if is_group:
            await bot.call_action(
                "upload_group_file",
                group_id=sid,
                file=file_path,
                name=seg.name or "file",
            )
        else:
            await bot.call_action(
                "upload_private_file",
                user_id=sid,
                file=file_path,
                name=seg.name or "file",
            )
        continue
    except Exception as e:
        from astrbot.api import logger
        logger.warning(f"上传文件失败,回退到直接发送: {e}")
References
  1. When implementing similar functionality for different cases (e.g., direct vs. quoted attachments), refactor the logic into a shared helper function to avoid code duplication.
  2. New functionality, such as handling attachments, should be accompanied by corresponding unit tests.

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 4 issues, and left some high level feedback:

  • Consider catching a more specific exception than Exception in the upload path (or at least using logger.exception) so that unexpected errors surface clearly while still logging a full traceback for genuine upload failures.
  • The from astrbot.api import logger inside the exception block will execute on every failure; if circular imports allow, moving this to module scope and reusing a module-level logger would avoid repeated imports in a hot path.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider catching a more specific exception than `Exception` in the upload path (or at least using `logger.exception`) so that unexpected errors surface clearly while still logging a full traceback for genuine upload failures.
- The `from astrbot.api import logger` inside the exception block will execute on every failure; if circular imports allow, moving this to module scope and reusing a module-level logger would avoid repeated imports in a hot path.

## Individual Comments

### Comment 1
<location path="astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py" line_range="179-182" />
<code_context>
+                file_path = seg.file
+                if file_path and session_id and session_id.isdigit():
+                    sid = int(session_id)
+                    try:
+                        if is_group:
+                            await bot.call_action(
+                                "upload_group_file",
+                                group_id=sid,
+                                file=file_path,
+                                name=seg.name or "file",
+                            )
+                        else:
+                            await bot.call_action(
+                                "upload_private_file",
+                                user_id=sid,
+                                file=file_path,
+                                name=seg.name or "file",
+                            )
+                        continue  # 上传成功,upload API 自带发送功能
+                    except Exception as e:
+                        from astrbot.api import logger
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Catching bare `Exception` may hide specific upload failures and stack traces.

Catching a broad `Exception` and only logging `e` drops the stack trace and obscures where the upload failed. Please narrow this to the expected exception type (e.g., the client’s error base class), or at least log the full traceback with something like `logger.exception` so failures remain diagnosable while still allowing the fallback path.

```suggestion
                    except Exception:
                        from astrbot.api import logger

                        logger.exception("上传文件失败,回退到直接发送")
```
</issue_to_address>

### Comment 2
<location path="tests/unit/test_aiocqhttp_file_message.py" line_range="20-29" />
<code_context>
+    assert "file:///" in str(fallbackMessage[0]["data"]["file"])
+
+
+@pytest.mark.asyncio
+async def test_no_local_file_falls_back_to_dispatch_send():
+    """无本地文件(纯 URL)时回退到 _from_segment_to_dict + _dispatch_send。"""
+    fileComp = Comp.File(name="remote.txt", url="https://example.com/remote.txt")
+    bot = MagicMock()
+    bot.send_group_msg = AsyncMock()
+    bot.send_private_msg = AsyncMock()
+    bot.call_action = AsyncMock()
+
+    await AiocqhttpMessageEvent.send_message(
+        bot=bot,
+        message_chain=MessageChain([fileComp]),
+        is_group=True,
+        session_id="123456",
+    )
+
+    # 无本地文件路径,不应调用 upload API
+    bot.call_action.assert_not_called()
+    # 回退到 send_group_msg
+    bot.send_group_msg.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_no_session_id_falls_back_to_dispatch_send(tmp_path):
+    """session_id 为 None 时回退到 _dispatch_send(会抛 ValueError)。"""
+    testFile = tmp_path / "test.txt"
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test for non-digit session_id to cover the `session_id.isdigit()` guard.

Currently we only cover `session_id=None`. Please also add a case where `session_id` is a non-empty, non-numeric string (e.g. `"abc123"`) to confirm the upload path is skipped and `_dispatch_send` is used instead. For example, a `test_non_digit_session_id_falls_back_to_dispatch_send` (for both group and private, if applicable) that:
- passes a `File` segment with `session_id="abc123"`;
- asserts `bot.call_action` is not called;
- asserts the appropriate fallback send path (`send_group_msg` / `send_private_msg`) or the expected exception from `_dispatch_send`.

Suggested implementation:

```python
    bot.call_action.assert_not_called()
    # 回退到 send_group_msg
    bot.send_group_msg.assert_called_once()


@pytest.mark.asyncio
async def test_non_digit_session_id_group_falls_back_to_dispatch_send(tmp_path):
    """群聊:session_id 为非数字字符串时应回退到 _dispatch_send,不调用 upload API。"""
    testFile = tmp_path / "test.txt"
    testFile.write_text("hello world", encoding="utf-8")

    fileComp = Comp.File(name="test.txt", file=str(testFile))
    bot = MagicMock()
    bot.send_group_msg = AsyncMock()
    bot.send_private_msg = AsyncMock()
    bot.call_action = AsyncMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([fileComp]),
        is_group=True,
        session_id="abc123",
    )

    # 非数字 session_id,上传 API 应被跳过
    bot.call_action.assert_not_called()
    # 回退到 send_group_msg
    bot.send_group_msg.assert_called_once()
    bot.send_private_msg.assert_not_called()


@pytest.mark.asyncio
async def test_non_digit_session_id_private_falls_back_to_dispatch_send(tmp_path):
    """私聊:session_id 为非数字字符串时应回退到 _dispatch_send,不调用 upload API。"""
    testFile = tmp_path / "test.txt"
    testFile.write_text("hello world", encoding="utf-8")

    fileComp = Comp.File(name="test.txt", file=str(testFile))
    bot = MagicMock()
    bot.send_group_msg = AsyncMock()
    bot.send_private_msg = AsyncMock()
    bot.call_action = AsyncMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([fileComp]),
        is_group=False,
        session_id="abc123",
    )

    # 非数字 session_id,上传 API 应被跳过
    bot.call_action.assert_not_called()
    # 回退到 send_private_msg
    bot.send_private_msg.assert_called_once()
    bot.send_group_msg.assert_not_called()

```

These edits assume that:
1. The snippet with `bot.call_action.assert_not_called()` and `bot.send_group_msg.assert_called_once()` belongs to `test_no_local_file_falls_back_to_dispatch_send`, and that `pytest`, `MagicMock`, `AsyncMock`, `Comp`, `MessageChain`, and `AiocqhttpMessageEvent` are already imported (as shown in the partial file).
2. No additional fixtures or helpers are required for these tests; if your existing tests use shared fixtures for `bot` construction or message setup, you may want to refactor these new tests to reuse those fixtures instead of instantiating `MagicMock`/`AsyncMock` directly.
</issue_to_address>

### Comment 3
<location path="tests/unit/test_aiocqhttp_file_message.py" line_range="185-45" />
<code_context>
+async def test_multiple_files_each_uses_upload_api(tmp_path):
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a private-chat variant for multiple-file uploads.

You already verify that each file in a group chat triggers a separate `upload_group_file` call. To fully exercise the new behavior, please add an analogous private-chat test (e.g. `test_multiple_files_each_uses_upload_private_api`) that checks:
- `bot.call_action` is invoked once per file with `"upload_private_file"` and the expected `user_id`/`file`/`name`.
- `send_private_msg` is never called.

This keeps group and private flows symmetric and protects against regressions in one path only.

Suggested implementation:

```python
@pytest.mark.asyncio
async def test_multiple_files_each_uses_upload_api(tmp_path):
    """多个 File 段:每个都独立调用 group upload API。"""
    file1 = tmp_path / "a.txt"
    file2 = tmp_path / "b.txt"
    file1.write_text("aaa", encoding="utf-8")
    file2.write_text("bbb", encoding="utf-8")

    comp1 = Comp.File(name="a.txt", file=str(file1))
    comp2 = Comp.File(name="b.txt", file=str(file2))

    bot = MagicMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([comp1, comp2]),
        is_group=True,
        session_id="123456",
    )

    assert bot.call_action.call_count == 2
    bot.call_action.assert_any_call(
        "upload_group_file",
        group_id="123456",
        file=str(file1),
        name="a.txt",
    )
    bot.call_action.assert_any_call(
        "upload_group_file",
        group_id="123456",
        file=str(file2),
        name="b.txt",
    )

    # 发送文件时不应直接调用发送消息接口
    assert not bot.send_group_msg.called


@pytest.mark.asyncio
async def test_multiple_files_each_uses_upload_private_api(tmp_path):
    """多个 File 段:每个都独立调用 private upload API。"""
    file1 = tmp_path / "a.txt"
    file2 = tmp_path / "b.txt"
    file1.write_text("aaa", encoding="utf-8")
    file2.write_text("bbb", encoding="utf-8")

    comp1 = Comp.File(name="a.txt", file=str(file1))
    comp2 = Comp.File(name="b.txt", file=str(file2))

    bot = MagicMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([comp1, comp2]),
        is_group=False,
        user_id=123456,
        session_id="123456",
    )

    assert bot.call_action.call_count == 2
    bot.call_action.assert_any_call(
        "upload_private_file",
        user_id=123456,
        file=str(file1),
        name="a.txt",
    )
    bot.call_action.assert_any_call(
        "upload_private_file",
        user_id=123456,
        file=str(file2),
        name="b.txt",
    )

    # 发送文件时不应直接调用发送私聊消息接口
    assert not bot.send_private_msg.called

```

1. The exact arguments to `AiocqhttpMessageEvent.send_message` (e.g. `group_id`, `user_id`, `session_id`) and the `upload_group_file`/`upload_private_file` call signatures should be aligned with the rest of this test file. You may need to adjust `group_id="123456"`/`user_id=123456`/`session_id="123456"` to match existing conventions.
2. If `ANY` from `unittest.mock` is preferred in your tests (to avoid hard-coding IDs), you can replace `123456` with `ANY` and ensure `from unittest.mock import ANY` is present at the top of the file.
3. If the existing `test_multiple_files_each_uses_upload_api` already has assertions beyond the snippet you provided, merge the new body carefully to avoid duplicating or conflicting assertions.
</issue_to_address>

### Comment 4
<location path="tests/unit/test_aiocqhttp_file_message.py" line_range="50-46" />
<code_context>
+
+
+@pytest.mark.asyncio
+async def test_file_private_uses_upload_private_file_api(tmp_path):
+    """私聊发送本地文件:应调用 upload_private_file API,不走 send_private_msg。"""
+    testFile = tmp_path / "doc.pdf"
+    testFile.write_text("pdf content", encoding="utf-8")
+
+    fileComp = Comp.File(name="doc.pdf", file=str(testFile))
+    bot = MagicMock()
+    bot.send_group_msg = AsyncMock()
+    bot.send_private_msg = AsyncMock()
+    bot.call_action = AsyncMock()
+
+    await AiocqhttpMessageEvent.send_message(
+        bot=bot,
+        message_chain=MessageChain([fileComp]),
+        is_group=False,
+        session_id="789012",
+    )
+
+    bot.call_action.assert_called_once_with(
+        "upload_private_file",
+        user_id=789012,
+        file=str(testFile.resolve()),
+        name="doc.pdf",
+    )
+    bot.send_private_msg.assert_not_called()
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test for defaulting `name` to 'file' when `seg.name` is missing.

Right now all tests pass a `File` with an explicit `name`. Since the implementation uses `seg.name or "file"`, please add a test (e.g. `test_file_without_name_defaults_to_file`) that:
- creates `Comp.File(file=str(testFile))` without a `name`;
- calls `send_message` for group/private;
- asserts `bot.call_action` is invoked with `name="file"`.

This ensures the default-name behavior is covered and guarded against regressions.

Suggested implementation:

```python
    bot.send_group_msg.assert_not_called()
    bot.send_private_msg.assert_not_called()


@pytest.mark.asyncio
async def test_file_group_without_name_defaults_to_file(tmp_path):
    """群聊发送本地文件且未提供 name:应使用默认 name='file' 并调用 upload_group_file。"""
    testFile = tmp_path / "test.txt"
    testFile.write_text("hello world", encoding="utf-8")

    fileComp = Comp.File(file=str(testFile))
    bot = MagicMock()
    bot.send_group_msg = AsyncMock()
    bot.send_private_msg = AsyncMock()
    bot.call_action = AsyncMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([fileComp]),
        is_group=True,
        session_id="123456",
    )

    bot.call_action.assert_called_once_with(
        "upload_group_file",
        group_id=123456,
        file=str(testFile.resolve()),
        name="file",
    )
    bot.send_group_msg.assert_not_called()
    bot.send_private_msg.assert_not_called()


@pytest.mark.asyncio
async def test_file_private_without_name_defaults_to_file(tmp_path):
    """私聊发送本地文件且未提供 name:应使用默认 name='file' 并调用 upload_private_file。"""
    testFile = tmp_path / "doc.pdf"
    testFile.write_text("pdf content", encoding="utf-8")

    fileComp = Comp.File(file=str(testFile))
    bot = MagicMock()
    bot.send_group_msg = AsyncMock()
    bot.send_private_msg = AsyncMock()
    bot.call_action = AsyncMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([fileComp]),
        is_group=False,
        session_id="789012",
    )

    bot.call_action.assert_called_once_with(
        "upload_private_file",
        user_id=789012,
        file=str(testFile.resolve()),
        name="file",
    )
    bot.send_private_msg.assert_not_called()


@pytest.mark.asyncio

```

These edits assume that `Comp`, `MessageChain`, and `AiocqhttpMessageEvent` are already imported earlier in `test_aiocqhttp_file_message.py`, consistent with the existing tests. If they are not, add the appropriate imports (mirroring how the existing file-upload tests reference them).
</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.

Comment thread astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py Outdated
Comment on lines +20 to +29
@pytest.mark.asyncio
async def test_file_group_uses_upload_group_file_api(tmp_path):
"""群聊发送本地文件:应调用 upload_group_file API,不走 send_group_msg。"""
testFile = tmp_path / "test.txt"
testFile.write_text("hello world", encoding="utf-8")

fileComp = Comp.File(name="test.txt", file=str(testFile))
bot = MagicMock()
bot.send_group_msg = AsyncMock()
bot.send_private_msg = AsyncMock()
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.

suggestion (testing): Add a test for non-digit session_id to cover the session_id.isdigit() guard.

Currently we only cover session_id=None. Please also add a case where session_id is a non-empty, non-numeric string (e.g. "abc123") to confirm the upload path is skipped and _dispatch_send is used instead. For example, a test_non_digit_session_id_falls_back_to_dispatch_send (for both group and private, if applicable) that:

  • passes a File segment with session_id="abc123";
  • asserts bot.call_action is not called;
  • asserts the appropriate fallback send path (send_group_msg / send_private_msg) or the expected exception from _dispatch_send.

Suggested implementation:

    bot.call_action.assert_not_called()
    # 回退到 send_group_msg
    bot.send_group_msg.assert_called_once()


@pytest.mark.asyncio
async def test_non_digit_session_id_group_falls_back_to_dispatch_send(tmp_path):
    """群聊:session_id 为非数字字符串时应回退到 _dispatch_send,不调用 upload API。"""
    testFile = tmp_path / "test.txt"
    testFile.write_text("hello world", encoding="utf-8")

    fileComp = Comp.File(name="test.txt", file=str(testFile))
    bot = MagicMock()
    bot.send_group_msg = AsyncMock()
    bot.send_private_msg = AsyncMock()
    bot.call_action = AsyncMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([fileComp]),
        is_group=True,
        session_id="abc123",
    )

    # 非数字 session_id,上传 API 应被跳过
    bot.call_action.assert_not_called()
    # 回退到 send_group_msg
    bot.send_group_msg.assert_called_once()
    bot.send_private_msg.assert_not_called()


@pytest.mark.asyncio
async def test_non_digit_session_id_private_falls_back_to_dispatch_send(tmp_path):
    """私聊:session_id 为非数字字符串时应回退到 _dispatch_send,不调用 upload API。"""
    testFile = tmp_path / "test.txt"
    testFile.write_text("hello world", encoding="utf-8")

    fileComp = Comp.File(name="test.txt", file=str(testFile))
    bot = MagicMock()
    bot.send_group_msg = AsyncMock()
    bot.send_private_msg = AsyncMock()
    bot.call_action = AsyncMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([fileComp]),
        is_group=False,
        session_id="abc123",
    )

    # 非数字 session_id,上传 API 应被跳过
    bot.call_action.assert_not_called()
    # 回退到 send_private_msg
    bot.send_private_msg.assert_called_once()
    bot.send_group_msg.assert_not_called()

These edits assume that:

  1. The snippet with bot.call_action.assert_not_called() and bot.send_group_msg.assert_called_once() belongs to test_no_local_file_falls_back_to_dispatch_send, and that pytest, MagicMock, AsyncMock, Comp, MessageChain, and AiocqhttpMessageEvent are already imported (as shown in the partial file).
  2. No additional fixtures or helpers are required for these tests; if your existing tests use shared fixtures for bot construction or message setup, you may want to refactor these new tests to reuse those fixtures instead of instantiating MagicMock/AsyncMock directly.

file=str(testFile.resolve()),
name="test.txt",
)
bot.send_group_msg.assert_not_called()
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.

suggestion (testing): Consider adding a private-chat variant for multiple-file uploads.

You already verify that each file in a group chat triggers a separate upload_group_file call. To fully exercise the new behavior, please add an analogous private-chat test (e.g. test_multiple_files_each_uses_upload_private_api) that checks:

  • bot.call_action is invoked once per file with "upload_private_file" and the expected user_id/file/name.
  • send_private_msg is never called.

This keeps group and private flows symmetric and protects against regressions in one path only.

Suggested implementation:

@pytest.mark.asyncio
async def test_multiple_files_each_uses_upload_api(tmp_path):
    """多个 File 段:每个都独立调用 group upload API。"""
    file1 = tmp_path / "a.txt"
    file2 = tmp_path / "b.txt"
    file1.write_text("aaa", encoding="utf-8")
    file2.write_text("bbb", encoding="utf-8")

    comp1 = Comp.File(name="a.txt", file=str(file1))
    comp2 = Comp.File(name="b.txt", file=str(file2))

    bot = MagicMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([comp1, comp2]),
        is_group=True,
        session_id="123456",
    )

    assert bot.call_action.call_count == 2
    bot.call_action.assert_any_call(
        "upload_group_file",
        group_id="123456",
        file=str(file1),
        name="a.txt",
    )
    bot.call_action.assert_any_call(
        "upload_group_file",
        group_id="123456",
        file=str(file2),
        name="b.txt",
    )

    # 发送文件时不应直接调用发送消息接口
    assert not bot.send_group_msg.called


@pytest.mark.asyncio
async def test_multiple_files_each_uses_upload_private_api(tmp_path):
    """多个 File 段:每个都独立调用 private upload API。"""
    file1 = tmp_path / "a.txt"
    file2 = tmp_path / "b.txt"
    file1.write_text("aaa", encoding="utf-8")
    file2.write_text("bbb", encoding="utf-8")

    comp1 = Comp.File(name="a.txt", file=str(file1))
    comp2 = Comp.File(name="b.txt", file=str(file2))

    bot = MagicMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([comp1, comp2]),
        is_group=False,
        user_id=123456,
        session_id="123456",
    )

    assert bot.call_action.call_count == 2
    bot.call_action.assert_any_call(
        "upload_private_file",
        user_id=123456,
        file=str(file1),
        name="a.txt",
    )
    bot.call_action.assert_any_call(
        "upload_private_file",
        user_id=123456,
        file=str(file2),
        name="b.txt",
    )

    # 发送文件时不应直接调用发送私聊消息接口
    assert not bot.send_private_msg.called
  1. The exact arguments to AiocqhttpMessageEvent.send_message (e.g. group_id, user_id, session_id) and the upload_group_file/upload_private_file call signatures should be aligned with the rest of this test file. You may need to adjust group_id="123456"/user_id=123456/session_id="123456" to match existing conventions.
  2. If ANY from unittest.mock is preferred in your tests (to avoid hard-coding IDs), you can replace 123456 with ANY and ensure from unittest.mock import ANY is present at the top of the file.
  3. If the existing test_multiple_files_each_uses_upload_api already has assertions beyond the snippet you provided, merge the new body carefully to avoid duplicating or conflicting assertions.

name="test.txt",
)
bot.send_group_msg.assert_not_called()
bot.send_private_msg.assert_not_called()
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.

suggestion (testing): Add a test for defaulting name to 'file' when seg.name is missing.

Right now all tests pass a File with an explicit name. Since the implementation uses seg.name or "file", please add a test (e.g. test_file_without_name_defaults_to_file) that:

  • creates Comp.File(file=str(testFile)) without a name;
  • calls send_message for group/private;
  • asserts bot.call_action is invoked with name="file".

This ensures the default-name behavior is covered and guarded against regressions.

Suggested implementation:

    bot.send_group_msg.assert_not_called()
    bot.send_private_msg.assert_not_called()


@pytest.mark.asyncio
async def test_file_group_without_name_defaults_to_file(tmp_path):
    """群聊发送本地文件且未提供 name:应使用默认 name='file' 并调用 upload_group_file。"""
    testFile = tmp_path / "test.txt"
    testFile.write_text("hello world", encoding="utf-8")

    fileComp = Comp.File(file=str(testFile))
    bot = MagicMock()
    bot.send_group_msg = AsyncMock()
    bot.send_private_msg = AsyncMock()
    bot.call_action = AsyncMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([fileComp]),
        is_group=True,
        session_id="123456",
    )

    bot.call_action.assert_called_once_with(
        "upload_group_file",
        group_id=123456,
        file=str(testFile.resolve()),
        name="file",
    )
    bot.send_group_msg.assert_not_called()
    bot.send_private_msg.assert_not_called()


@pytest.mark.asyncio
async def test_file_private_without_name_defaults_to_file(tmp_path):
    """私聊发送本地文件且未提供 name:应使用默认 name='file' 并调用 upload_private_file。"""
    testFile = tmp_path / "doc.pdf"
    testFile.write_text("pdf content", encoding="utf-8")

    fileComp = Comp.File(file=str(testFile))
    bot = MagicMock()
    bot.send_group_msg = AsyncMock()
    bot.send_private_msg = AsyncMock()
    bot.call_action = AsyncMock()

    await AiocqhttpMessageEvent.send_message(
        bot=bot,
        message_chain=MessageChain([fileComp]),
        is_group=False,
        session_id="789012",
    )

    bot.call_action.assert_called_once_with(
        "upload_private_file",
        user_id=789012,
        file=str(testFile.resolve()),
        name="file",
    )
    bot.send_private_msg.assert_not_called()


@pytest.mark.asyncio

These edits assume that Comp, MessageChain, and AiocqhttpMessageEvent are already imported earlier in test_aiocqhttp_file_message.py, consistent with the existing tests. If they are not, add the appropriate imports (mirroring how the existing file-upload tests reference them).

- 增加对文件路径存在性的检查,确保文件可用
- 优化异常处理,记录详细错误信息
- 添加多个文件上传的测试用例,确保功能正常
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. and removed size:S This PR changes 10-29 lines, ignoring generated files. labels Jun 1, 2026
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:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] aiocqhttp 平台下 File 组件无法发送文件,缺少 upload_private_file 流程

1 participant