Skip to content

feat: 为QQ官机适配大文件和视频能力#9218

Open
u0-ani-nya wants to merge 2 commits into
AstrBotDevs:masterfrom
u0-ani-nya:master
Open

feat: 为QQ官机适配大文件和视频能力#9218
u0-ani-nya wants to merge 2 commits into
AstrBotDevs:masterfrom
u0-ani-nya:master

Conversation

@u0-ani-nya

@u0-ani-nya u0-ani-nya commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Modifications / 改动点

问题

QQ 官方机器人适配器上传视频/文件时,将文件读入内存后 Base64 编码塞进 JSON POST,体积膨胀约 33%。
当文件较大时(如 20MB+),膨胀后的请求体超过腾讯 STGW 的 body 限制,触发 413 Request Entity Too Large

根因:适配器使用 base64.b64encode(file_content) 后放入 JSON 的 file_data 字段,而 QQ 客户端走的是分片上传协议,两者链路完全不同。

方案

复用 AstrBot 已有的 Web Server 能力,为大文件生成临时公网 URL,让 QQ 服务器直接下载文件,避免 Base64 膨胀。

核心流程

启动 → 探测 callback_api_base 是否可达(GET 面板根路径,最多重试 3 次,间隔 3 秒)
                ↓
上传视频/文件 → 复制到 data/media_upload_cache/{uuid}.ext
                ↓
        生成临时 URL: {callback_api_base}/api/media/{uuid}.ext
                ↓
        QQ 服务器通过 URL 下载文件(重试 3 次,间隔 3 秒)
              ↙         ↘
          成功            失败
           ↓               ↓
     清理临时文件      清理临时文件 + 回退 Base64

渐进降级

场景 行为
callback_api_base 未配置 直接 Base64(原有行为)
探测不可达 回退 Base64
URL 上传失败(3 次重试) 回退 Base64
音频/图片 保持 Base64(体积小,无需 URL 模式)

改动文件

文件 改动
qqofficial_message_event.py URL 上传逻辑、临时文件管理、启动探测
qqofficial_platform_adapter.py 启动时调用探测(6 行)
astrbot/dashboard/api/files.py 复用服务器 提供url上传

用户配置

在 AstrBot 设置中填写已有的 callback_api_base(对外可达的回调接口地址),如:

  • http://your-ip:6185
  • https://your-domain.com

留空则使用 Base64 上传(原有行为不变),无需新增配置项。

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

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

[2026-07-10 19:41:54.734] [Core]
[INFO]
[core.event_bus:79]: [default] [QQ(qq_official)] 7BCDD80: [At:qq_official] https://x.com/Mik*********47733032?s=20
[2026-07-10 19:41:54.740] [Plug]
[INFO]
[llm_debug_logger.main:45]: [LLM-Debug] #7 ① 管道入口 [私聊] is_at=True text=https://x.com/Mik*********47733032?s=20
[2026-07-10 19:41:54.740] [Plug]
[INFO]
[llm_debug_logger.main:55]: [LLM-Debug] #7 📦 raw_message type: PatchedC2CMessage
[2026-07-10 19:41:54.740] [Plug]
[INFO]
[llm_debug_logger.main:109]: [LLM-Debug] #7 📦 raw_message 属性:
{
"attachments": "[]",
"author": "<_User: {'user_openid': '7BCD****************D80'}>",
"content": "https://x.com/Mik*********47733032?s=20 ",
"event_id": "C2C_MESSAGE_CREATE:ruuuxsoz2",
"id": "ROBOT1.0_8GsQWq3Z7lV7XL-4!",
"mentions": "[]",
"message_reference": "<_MessageRef: {'message_id': None}>",
"message_type": 0,
"msg_elements": "[]",
"msg_seq": null,
"raw_data": "{'author': {'bot': False, 'id': '7BCD
D80', 'union_openid': '', 'user_openid': '7BCDD80', 'username': ''}, 'content': 'https://x.com/Mik*********47733032?s=20 ', 'id': 'ROBOT1.0_8GsQuJqP057XzXTL-4!', 'message_scene': {'ext': ['msg_idx=REFIDX_Gcq/fk*******ppK6Gc='], 'source': 'default'}, 'message_type': 0, 'timestamp'",
"timestamp": "2026-07-11T01:41:53+08:00"
}
[2026-07-10 19:41:54.740] [Plug]
[INFO]
[llm_debug_logger.main:198]: [LLM-Debug] #7 🔗 提取到链接 (content): https://x.com/Mik*********47733032?s=20
[2026-07-10 19:41:57.654] [Core]
[INFO]
[qqofficial.qqofficial_message_event:689]: [QQOfficial] 临时文件: /AstrBot/data/media_upload_cache/dafdaf063f474
f66cf.mp4
[2026-07-10 19:41:57.654] [Core]
[INFO]
[qqofficial.qqofficial_message_event:690]: [QQOfficial] 下载 URL: https://website/api/media/dafdaf063f474ef4***********66cf.mp4
[2026-07-10 19:42:08.400] [Core]
[WARN]
[v4.26.5] [qqofficial.qqofficial_message_event:747]: [QQOfficial] URL 上传失败 (系统繁忙,请稍后重试),重试...
[2026-07-10 19:42:17.598] [Core]
[INFO]
[qqofficial.qqofficial_message_event:751]: [QQOfficial] URL 上传成功: /AstrBot/data/plugin_data/astrbot_plugin_media_parser/cache/twitter_af497ff6_1783705314_536deb53/video_0.mp4
[2026-07-10 19:42:17.603] [Core]
[INFO]
[qqofficial.qqofficial_message_event:699]: [QQOfficial] 已清理临时文件: /AstrBot/data/media_upload_cache/dafdaf063f474ef48ec26f32a34f66cf.mp4
屏幕截图_20260711_014050


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

Add URL-based and chunked upload support for large media in the QQ official adapter while keeping a Base64 fallback path.

New Features:

  • Serve media cache files via a new /api/media/{filename} endpoint without authentication to allow QQ servers to download temporary uploads.

Enhancements:

  • Probe callback_api_base reachability at startup and cache URL upload availability for the QQ official adapter, including cleanup of stale media cache files.
  • Implement chunked upload for local video and file messages in the QQ official adapter, with detailed logging and automatic downgrade of oversized videos to file messages, falling back to Base64 uploads when needed.

Copilot AI review requested due to automatic review settings July 12, 2026 07:49
@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Jul 12, 2026

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@dosubot dosubot Bot added the area:platform The bug / feature is about IM platform adapter, such as QQ, Lark, Telegram, WebChat and so on. label Jul 12, 2026

media_cache_dir = os.path.join(os.getcwd(), "data", "media_upload_cache")
file_path = os.path.join(media_cache_dir, filename)
if not os.path.isfile(file_path):
if not os.path.isfile(file_path):
raise HTTPException(status_code=404)

return FileResponse(file_path)

@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 3 issues, and left some high level feedback:

  • The new chunked upload implementation opens a fresh aiohttp.ClientSession and loads each part fully into memory for every PUT; consider reusing a single session and streaming file chunks to reduce overhead and memory footprint on large files.
  • Temporary media files in data/media_upload_cache are only cleaned on startup; for long-running instances you may want a periodic cleanup or TTL-based deletion to avoid unbounded disk usage.
  • Most of the new '[QQOfficial][DEBUG]' logs are emitted at INFO level, which could be noisy in production; consider moving these to DEBUG or reducing verbosity once the feature stabilizes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new chunked upload implementation opens a fresh `aiohttp.ClientSession` and loads each part fully into memory for every PUT; consider reusing a single session and streaming file chunks to reduce overhead and memory footprint on large files.
- Temporary media files in `data/media_upload_cache` are only cleaned on startup; for long-running instances you may want a periodic cleanup or TTL-based deletion to avoid unbounded disk usage.
- Most of the new '[QQOfficial][DEBUG]' logs are emitted at `INFO` level, which could be noisy in production; consider moving these to `DEBUG` or reducing verbosity once the feature stabilizes.

## Individual Comments

### Comment 1
<location path="astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py" line_range="972-979" />
<code_context>
+                )
+
+        # 顺序上传所有分片(确保 part_finish 按序完成)
+        logger.info(f"[QQOfficial] 分片顺序: {[int(p.get('index', 0)) for p in parts]}")
+        for part in parts:
+            try:
+                await _upload_one_part(part)
</code_context>
<issue_to_address>
**suggestion (performance):** 分片上传当前是完全串行的,`concurrency` 配置和信号量实际上未生效

当前虽然配置了 `concurrency` 并使用了 `asyncio.Semaphore`,但这里仍是简单的顺序 `for part in parts: await _upload_one_part(part)`,实际没有任何并发,上传大文件/多分片时会明显变慢。如果平台不要求 `upload_part_finish` 严格按 `index` 顺序到达,建议用 `asyncio.gather` 或基于队列的并发模式(由信号量控制最大并发数)来执行 `_upload_one_part`。若确实需要 `part_finish` 顺序,只在内部串行处理 `part_finish` 调用,保持 PUT 上传本身的并发。

```suggestion
        # 并发上传所有分片(由外部 concurrency / 信号量控制最大并发)
        logger.info(f"[QQOfficial] 分片顺序: {[int(p.get('index', 0)) for p in parts]}")

        upload_tasks = [
            asyncio.create_task(_upload_one_part(part))
            for part in parts
        ]

        results = await asyncio.gather(*upload_tasks, return_exceptions=True)

        # 统一检查异常,确保任意一个分片失败都会中止整个上传流程
        for idx, result in enumerate(results):
            if isinstance(result, Exception):
                logger.error(
                    f"[QQOfficial] 分片上传失败: part_index={parts[idx].get('index')}, "
                    f"error={result!r}"
                )
                return None
```
</issue_to_address>

### Comment 2
<location path="astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py" line_range="905-906" />
<code_context>
+                last_exc = None
+                for attempt in range(self._CHUNKED_PART_UPLOAD_MAX_RETRIES + 1):
+                    try:
+                        async with aiohttp.ClientSession() as sess:
+                            async with sess.put(
+                                presigned_url,
+                                data=data,
</code_context>
<issue_to_address>
**suggestion (performance):** 每个分片上传都新建 `ClientSession` 可能带来较大开销,建议复用会话

当前在 `_upload_one_part` 中为每个分片创建 `aiohttp.ClientSession`,在几十到上百个分片场景下会产生大量 TCP 连接和 TLS 握手,难以复用连接。建议在 `_chunked_upload` 外层使用一次 `async with aiohttp.ClientSession() as sess:` 包裹所有分片上传,并通过参数或闭包将 `sess` 传入 `_upload_one_part`,以减少资源开销并提升上传稳定性。

Suggested implementation:

```python
            async with semaphore:
                # PUT 到预签名 URL
                last_exc = None
                for attempt in range(self._CHUNKED_PART_UPLOAD_MAX_RETRIES + 1):
                    try:
                        async with sess.put(
                            presigned_url,
                            data=data,
                            headers={"Content-Length": str(len(data))},
                            timeout=aiohttp.ClientTimeout(total=self._CHUNKED_PART_UPLOAD_TIMEOUT),
                        ) as resp:
                            resp.raise_for_status()
                            logger.info(
                                f"[QQOfficial] PUT 分片 {part_index}/{len(parts)}: {resp.status} OK, size={len(data)}"
                            )
                            break
                    except Exception as exc:
                        last_exc = exc
                        if attempt < self._CHUNKED_PART_UPLOAD_MAX_RETRIES:
                            delay = 1.0 * (2 ** attempt)

```

要完全实现会话复用,需要在外层(例如 `_chunked_upload` 或调用 `_upload_one_part` 的函数)中:

1. 使用一次性的会话上下文包裹所有分片上传逻辑,例如:
   ```python
   async with aiohttp.ClientSession() as sess:
       # 在这里启动所有分片任务,并把 sess 传入
   ```
2. 修改 `_upload_one_part` 的函数签名,增加一个 `sess: aiohttp.ClientSession` 参数,或者通过闭包捕获 `sess`,例如:
   ```python
   async def _upload_one_part(..., sess: aiohttp.ClientSession, ...):
       ...
   ```
3. 在创建分片任务时,传入该 `sess````python
   tasks.append(self._upload_one_part(..., sess=sess, ...))
   ```

如果当前代码结构不便于修改函数签名,可以选择在 `_chunked_upload` 内部定义一个内部协程,捕获外层的 `sess` 变量,然后替代现有的 `_upload_one_part` 调用。
</issue_to_address>

### Comment 3
<location path="astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py" line_range="91" />
<code_context>
+_media_upload_base_url: str = ""
+
+
+async def init_url_upload_probe(media_upload_url: str = "") -> None:
+    """启动时探测媒体上传地址是否可达,结果缓存。
+    应在适配器启动时调用一次。同时清理上次遗留的临时文件。
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the QQOfficial adapter by removing or extracting unused URL-upload helpers, decomposing large upload and parsing methods into focused helpers/classes, and centralizing repeated logging to make control flow easier to follow.

- `_prepare_temp_file`, `_cleanup_temp_file`, `_probe_temp_url_reachable`, `init_url_upload_probe` and the globals `_url_upload_available`, `_media_upload_base_url` are currently unused in the upload flow. They add extra concepts (URL upload mode, temp cache directory, probe) without being exercised.
  - If URL-upload is not actually supported yet in this adapter, you can reduce cognitive load by removing them for now (or moving them to a dedicated `media_url_upload.py` and wiring them in when the feature is complete).
  - Example: keep only the probe you actually call, and strip temp-file logic until it’s needed:
    ```python
    # media_url_upload.py
    _url_upload_available: bool | None = None
    _media_upload_base_url: str = ""

    async def init_url_upload_probe(media_upload_url: str = "") -> None:
        # ... as today, but only URL probe ...
    ```

- `upload_group_and_c2c_media` is now doing multiple things in-line (local vs remote, chunked vs base64, logging, retries), which makes the method long and harder to scan.
  - You can keep the new behavior but split into small helpers so the main method is an orchestrator:
    ```python
    async def upload_group_and_c2c_media(
        self, file_source: str, file_type: int, srv_send_msg: bool = False,
        file_name: str | None = None, **kwargs,
    ) -> Media | None:
        route, payload = self._build_media_payload(file_type, srv_send_msg, file_name, **kwargs)
        if route is None:
            return None

        if os.path.exists(file_source) and file_type in (self.VIDEO_FILE_TYPE, self.FILE_FILE_TYPE):
            media = await self._upload_via_chunked(file_source, file_type, file_name or "file", route, payload, **kwargs)
            if media:
                return media
            logger.warning("[QQOfficial] 分片上传失败,回退 Base64")

        return await self._upload_via_base64(file_source, route, payload)

    def _build_media_payload(...):
        # small helper: construct Route + base fields only

    async def _upload_via_chunked(...):
        return await self._chunked_upload(file_source, file_type, file_name, **kwargs)

    async def _upload_via_base64(self, file_source: str, route: Route, payload: dict) -> Media | None:
        if os.path.exists(file_source):
            async with aiofiles.open(file_source, "rb") as f:
                file_content = await f.read()
            payload["file_data"] = base64.b64encode(file_content).decode("utf-8")
        else:
            payload["url"] = file_source
        # reuse _qqofficial_retry wrapper here
    ```
  - This keeps `upload_group_and_c2c_media` small and makes feature-specific paths clearer.

- `_chunked_upload` is a large, stateful method inside the event class; it mixes hash computation, part IO, retry/backoff, and API orchestration.
  - Consider extracting a dedicated helper (e.g. `QQChunkedUploader`) so the event class only calls it:
    ```python
    class QQChunkedUploader:
        def __init__(self, http_client):
            self._http = http_client

        async def upload(self, file_path: str, file_type: int, file_name: str, **kwargs) -> Media | None:
            # move _compute_file_hashes, _read_file_chunk, upload_prepare, part PUT/finish, complete_upload here
    ```

    ```python
    # in QQOfficialMessageEvent.__init__
    self._chunked_uploader = QQChunkedUploader(self.bot.api._http)

    async def _upload_via_chunked(...):
        return await self._chunked_uploader.upload(file_source, file_type, file_name, **kwargs)
    ```
  - This keeps `QQOfficialMessageEvent` focused on messaging and isolates protocol-specific upload logic.

- The video-size downgrade logic is inlined inside `_parse_to_qqofficial`, mixing message parsing with transport policy.
  - You can extract it into a small helper without changing behavior:
    ```python
    async def _maybe_downgrade_video_to_file(
        self, video_file_source: str | None, file_name: str | None
    ) -> tuple[str | None, str | None, str | None]:
        if not video_file_source:
            return None, None, file_name

        file_source = None
        # copy the existing size-check / HEAD logic here
        # return (file_source, video_file_source, updated_file_name)

    async def _parse_to_qqofficial(...):
        # after resolving video_file_source, file_name:
        file_source, video_file_source, file_name = await self._maybe_downgrade_video_to_file(
            video_file_source, file_name
        )
    ```
  - This leaves `_parse_to_qqofficial` primarily responsible for building the message representation.

- The detailed `[DEBUG]` logging in `_post_send_one` and upload methods increases line count and makes control flow harder to visually follow.
  - You can preserve the logging but move repeated patterns into small helpers:
    ```python
    def _log_payload_state(self, tag: str, payload: dict) -> None:
        logger.info(
            f"[QQOfficial][DEBUG] {tag}: msg_type={payload.get('msg_type')}, "
            f"has_media={payload.get('media') is not None}, "
            f"has_markdown={payload.get('markdown') is not None}"
        )

    # usage:
    self._log_payload_state("发送前 payload 状态", payload)
    ```
  - This reduces clutter and makes the intent of each call clearer.
</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 on lines +972 to +979
# 顺序上传所有分片(确保 part_finish 按序完成)
logger.info(f"[QQOfficial] 分片顺序: {[int(p.get('index', 0)) for p in parts]}")
for part in parts:
try:
await _upload_one_part(part)
except Exception as e:
logger.error(f"[QQOfficial] 分片上传失败: {e}")
return None

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 (performance): 分片上传当前是完全串行的,concurrency 配置和信号量实际上未生效

当前虽然配置了 concurrency 并使用了 asyncio.Semaphore,但这里仍是简单的顺序 for part in parts: await _upload_one_part(part),实际没有任何并发,上传大文件/多分片时会明显变慢。如果平台不要求 upload_part_finish 严格按 index 顺序到达,建议用 asyncio.gather 或基于队列的并发模式(由信号量控制最大并发数)来执行 _upload_one_part。若确实需要 part_finish 顺序,只在内部串行处理 part_finish 调用,保持 PUT 上传本身的并发。

Suggested change
# 顺序上传所有分片(确保 part_finish 按序完成)
logger.info(f"[QQOfficial] 分片顺序: {[int(p.get('index', 0)) for p in parts]}")
for part in parts:
try:
await _upload_one_part(part)
except Exception as e:
logger.error(f"[QQOfficial] 分片上传失败: {e}")
return None
# 并发上传所有分片(由外部 concurrency / 信号量控制最大并发)
logger.info(f"[QQOfficial] 分片顺序: {[int(p.get('index', 0)) for p in parts]}")
upload_tasks = [
asyncio.create_task(_upload_one_part(part))
for part in parts
]
results = await asyncio.gather(*upload_tasks, return_exceptions=True)
# 统一检查异常,确保任意一个分片失败都会中止整个上传流程
for idx, result in enumerate(results):
if isinstance(result, Exception):
logger.error(
f"[QQOfficial] 分片上传失败: part_index={parts[idx].get('index')}, "
f"error={result!r}"
)
return None

Comment on lines +905 to +906
async with aiohttp.ClientSession() as sess:
async with sess.put(

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 (performance): 每个分片上传都新建 ClientSession 可能带来较大开销,建议复用会话

当前在 _upload_one_part 中为每个分片创建 aiohttp.ClientSession,在几十到上百个分片场景下会产生大量 TCP 连接和 TLS 握手,难以复用连接。建议在 _chunked_upload 外层使用一次 async with aiohttp.ClientSession() as sess: 包裹所有分片上传,并通过参数或闭包将 sess 传入 _upload_one_part,以减少资源开销并提升上传稳定性。

Suggested implementation:

            async with semaphore:
                # PUT 到预签名 URL
                last_exc = None
                for attempt in range(self._CHUNKED_PART_UPLOAD_MAX_RETRIES + 1):
                    try:
                        async with sess.put(
                            presigned_url,
                            data=data,
                            headers={"Content-Length": str(len(data))},
                            timeout=aiohttp.ClientTimeout(total=self._CHUNKED_PART_UPLOAD_TIMEOUT),
                        ) as resp:
                            resp.raise_for_status()
                            logger.info(
                                f"[QQOfficial] PUT 分片 {part_index}/{len(parts)}: {resp.status} OK, size={len(data)}"
                            )
                            break
                    except Exception as exc:
                        last_exc = exc
                        if attempt < self._CHUNKED_PART_UPLOAD_MAX_RETRIES:
                            delay = 1.0 * (2 ** attempt)

要完全实现会话复用,需要在外层(例如 _chunked_upload 或调用 _upload_one_part 的函数)中:

  1. 使用一次性的会话上下文包裹所有分片上传逻辑,例如:
    async with aiohttp.ClientSession() as sess:
        # 在这里启动所有分片任务,并把 sess 传入
  2. 修改 _upload_one_part 的函数签名,增加一个 sess: aiohttp.ClientSession 参数,或者通过闭包捕获 sess,例如:
    async def _upload_one_part(..., sess: aiohttp.ClientSession, ...):
        ...
  3. 在创建分片任务时,传入该 sess
    tasks.append(self._upload_one_part(..., sess=sess, ...))

如果当前代码结构不便于修改函数签名,可以选择在 _chunked_upload 内部定义一个内部协程,捕获外层的 sess 变量,然后替代现有的 _upload_one_part 调用。

_media_upload_base_url: str = ""


async def init_url_upload_probe(media_upload_url: str = "") -> None:

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 simplifying the QQOfficial adapter by removing or extracting unused URL-upload helpers, decomposing large upload and parsing methods into focused helpers/classes, and centralizing repeated logging to make control flow easier to follow.

  • _prepare_temp_file, _cleanup_temp_file, _probe_temp_url_reachable, init_url_upload_probe and the globals _url_upload_available, _media_upload_base_url are currently unused in the upload flow. They add extra concepts (URL upload mode, temp cache directory, probe) without being exercised.

    • If URL-upload is not actually supported yet in this adapter, you can reduce cognitive load by removing them for now (or moving them to a dedicated media_url_upload.py and wiring them in when the feature is complete).
    • Example: keep only the probe you actually call, and strip temp-file logic until it’s needed:
      # media_url_upload.py
      _url_upload_available: bool | None = None
      _media_upload_base_url: str = ""
      
      async def init_url_upload_probe(media_upload_url: str = "") -> None:
          # ... as today, but only URL probe ...
  • upload_group_and_c2c_media is now doing multiple things in-line (local vs remote, chunked vs base64, logging, retries), which makes the method long and harder to scan.

    • You can keep the new behavior but split into small helpers so the main method is an orchestrator:
      async def upload_group_and_c2c_media(
          self, file_source: str, file_type: int, srv_send_msg: bool = False,
          file_name: str | None = None, **kwargs,
      ) -> Media | None:
          route, payload = self._build_media_payload(file_type, srv_send_msg, file_name, **kwargs)
          if route is None:
              return None
      
          if os.path.exists(file_source) and file_type in (self.VIDEO_FILE_TYPE, self.FILE_FILE_TYPE):
              media = await self._upload_via_chunked(file_source, file_type, file_name or "file", route, payload, **kwargs)
              if media:
                  return media
              logger.warning("[QQOfficial] 分片上传失败,回退 Base64")
      
          return await self._upload_via_base64(file_source, route, payload)
      
      def _build_media_payload(...):
          # small helper: construct Route + base fields only
      
      async def _upload_via_chunked(...):
          return await self._chunked_upload(file_source, file_type, file_name, **kwargs)
      
      async def _upload_via_base64(self, file_source: str, route: Route, payload: dict) -> Media | None:
          if os.path.exists(file_source):
              async with aiofiles.open(file_source, "rb") as f:
                  file_content = await f.read()
              payload["file_data"] = base64.b64encode(file_content).decode("utf-8")
          else:
              payload["url"] = file_source
          # reuse _qqofficial_retry wrapper here
    • This keeps upload_group_and_c2c_media small and makes feature-specific paths clearer.
  • _chunked_upload is a large, stateful method inside the event class; it mixes hash computation, part IO, retry/backoff, and API orchestration.

    • Consider extracting a dedicated helper (e.g. QQChunkedUploader) so the event class only calls it:

      class QQChunkedUploader:
          def __init__(self, http_client):
              self._http = http_client
      
          async def upload(self, file_path: str, file_type: int, file_name: str, **kwargs) -> Media | None:
              # move _compute_file_hashes, _read_file_chunk, upload_prepare, part PUT/finish, complete_upload here
      # in QQOfficialMessageEvent.__init__
      self._chunked_uploader = QQChunkedUploader(self.bot.api._http)
      
      async def _upload_via_chunked(...):
          return await self._chunked_uploader.upload(file_source, file_type, file_name, **kwargs)
    • This keeps QQOfficialMessageEvent focused on messaging and isolates protocol-specific upload logic.

  • The video-size downgrade logic is inlined inside _parse_to_qqofficial, mixing message parsing with transport policy.

    • You can extract it into a small helper without changing behavior:
      async def _maybe_downgrade_video_to_file(
          self, video_file_source: str | None, file_name: str | None
      ) -> tuple[str | None, str | None, str | None]:
          if not video_file_source:
              return None, None, file_name
      
          file_source = None
          # copy the existing size-check / HEAD logic here
          # return (file_source, video_file_source, updated_file_name)
      
      async def _parse_to_qqofficial(...):
          # after resolving video_file_source, file_name:
          file_source, video_file_source, file_name = await self._maybe_downgrade_video_to_file(
              video_file_source, file_name
          )
    • This leaves _parse_to_qqofficial primarily responsible for building the message representation.
  • The detailed [DEBUG] logging in _post_send_one and upload methods increases line count and makes control flow harder to visually follow.

    • You can preserve the logging but move repeated patterns into small helpers:
      def _log_payload_state(self, tag: str, payload: dict) -> None:
          logger.info(
              f"[QQOfficial][DEBUG] {tag}: msg_type={payload.get('msg_type')}, "
              f"has_media={payload.get('media') is not None}, "
              f"has_markdown={payload.get('markdown') is not None}"
          )
      
      # usage:
      self._log_payload_state("发送前 payload 状态", payload)
    • This reduces clutter and makes the intent of each call clearer.

@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 chunked upload mechanism for the QQOfficial platform adapter to support large files and videos, along with a fallback to Base64 upload and video size checks to downgrade large videos to file sending. The reviewer feedback highlights several critical areas for improvement: first, the chunked upload logic needs to handle the 'Instant Upload' (秒传) scenario where the prepare API directly returns a file UUID; second, the chunked upload currently runs serially and should be refactored to run in parallel using asyncio.gather with proper memory management and accompanied by unit tests; finally, several unused components added for a temporary URL upload scheme—including file preparation/cleanup utilities, startup probe logic, and a FastAPI route—should be removed as dead code.

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 +853 to +858
upload_id = prepare_result.get("upload_id", "")
block_size = int(prepare_result.get("block_size", 0))
parts = prepare_result.get("parts", [])
if not upload_id or not parts:
logger.error(f"[QQOfficial] upload_prepare 缺少必要字段: {prepare_result}")
return None

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

未处理秒传(Instant Upload)逻辑导致上传失败

在 QQ 机器人官方 API 的分片上传流程中,如果文件之前已经被上传过(秒传场景),/upload_prepare 接口会直接返回已完成的文件信息(包含 file_uuidfile_info),而不会返回 upload_idparts

但在当前实现中,代码强制校验了 upload_idparts。这会导致在触发秒传时,代码会误判为“缺少必要字段”并返回 None 导致上传失败,进而回退到 Base64 上传(如果文件超大还会触发 413 错误)。

建议改进:
在校验 upload_id 之前,先检查 prepare_result 中是否已包含 file_uuid。如果存在,则直接返回 Media 对象,实现秒传。

Suggested change
upload_id = prepare_result.get("upload_id", "")
block_size = int(prepare_result.get("block_size", 0))
parts = prepare_result.get("parts", [])
if not upload_id or not parts:
logger.error(f"[QQOfficial] upload_prepare 缺少必要字段: {prepare_result}")
return None
if "file_uuid" in prepare_result:
logger.info(f"[QQOfficial] 分片上传: 触发秒传成功! file_uuid={prepare_result['file_uuid']}")
return Media(
file_uuid=prepare_result["file_uuid"],
file_info=prepare_result.get("file_info", ""),
ttl=prepare_result.get("ttl", 0),
)
upload_id = prepare_result.get("upload_id", "")
block_size = int(prepare_result.get("block_size", 0))
parts = prepare_result.get("parts", [])
if not upload_id or not parts:
logger.error(f"[QQOfficial] upload_prepare 缺少必要字段: {prepare_result}")
return None

Comment on lines +882 to +979
async def _upload_one_part(part: dict):
nonlocal completed_parts
part_index = int(part.get("index", 0))
presigned_url = part.get("presigned_url", "")
part_block_size = int(part.get("block_size", 0)) or block_size
offset = (part_index - 1) * block_size
length = min(part_block_size, file_size - offset)

if not presigned_url:
logger.error(f"[QQOfficial] 分片 {part_index} 缺少 presigned_url")
return

# 读取分片数据
data = await asyncio.get_running_loop().run_in_executor(
None, self._read_file_chunk, file_source, offset, length
)
part_md5 = hashlib.md5(data).hexdigest()

async with semaphore:
# PUT 到预签名 URL
last_exc = None
for attempt in range(self._CHUNKED_PART_UPLOAD_MAX_RETRIES + 1):
try:
async with aiohttp.ClientSession() as sess:
async with sess.put(
presigned_url,
data=data,
headers={"Content-Length": str(len(data))},
timeout=aiohttp.ClientTimeout(total=self._CHUNKED_PART_UPLOAD_TIMEOUT),
) as resp:
resp.raise_for_status()
logger.info(f"[QQOfficial] PUT 分片 {part_index}/{len(parts)}: {resp.status} OK, size={len(data)}")
break
except Exception as exc:
last_exc = exc
if attempt < self._CHUNKED_PART_UPLOAD_MAX_RETRIES:
delay = 1.0 * (2 ** attempt)
logger.warning(f"[QQOfficial] PUT 分片 {part_index} 失败, 重试 {attempt+1}: {exc}")
await asyncio.sleep(delay)
else:
logger.error(f"[QQOfficial] PUT 分片 {part_index} 最终失败: {last_exc}")
raise RuntimeError(f"Part {part_index} upload failed: {last_exc}")

# 通知平台分片完成
# 发送 part 的 block_size(来自 prepare 响应),不是实际数据长度
part_finish_block_size = int(part.get("block_size", 0)) or block_size
part_finish_payload = {
"upload_id": upload_id,
"part_index": part_index,
"block_size": part_finish_block_size,
"md5": part_md5,
}
if openid:
finish_route = Route("POST", "/v2/users/{openid}/upload_part_finish", openid=openid)
else:
finish_route = Route("POST", "/v2/groups/{group_openid}/upload_part_finish", group_openid=group_openid)

logger.info(
f"[QQOfficial] part_finish 请求: part={part_index}, block_size={part_finish_block_size}, length={length}, md5={part_md5[:16]}..."
)
finish_start = asyncio.get_running_loop().time()
finish_attempt = 0
max_finish_retries = 5
while True:
try:
await self.bot.api._http.request(finish_route, json=part_finish_payload)
break
except Exception as exc:
err_msg = str(exc)
finish_attempt += 1
elapsed = asyncio.get_running_loop().time() - finish_start
# 40093001 = retryable (平台侧还没收到分片),其他错误也重试但次数更少
if "40093001" in err_msg:
if elapsed >= retry_timeout:
raise RuntimeError(f"part_finish timeout after {elapsed:.0f}s: {exc}") from exc
logger.debug(f"[QQOfficial] part_finish retryable(40093001), attempt {finish_attempt}")
await asyncio.sleep(self._CHUNKED_PART_FINISH_RETRY_INTERVAL)
else:
if finish_attempt >= max_finish_retries:
raise RuntimeError(f"part_finish failed after {finish_attempt} attempts: {exc}") from exc
delay = 1.0 * (2 ** (finish_attempt - 1))
logger.warning(f"[QQOfficial] part_finish 失败, 重试 {finish_attempt}/{max_finish_retries}: {exc}")
await asyncio.sleep(delay)

completed_parts += 1
logger.info(
f"[QQOfficial] 分片 {part_index}/{len(parts)} 完成 "
f"({completed_parts}/{len(parts)} done)"
)

# 顺序上传所有分片(确保 part_finish 按序完成)
logger.info(f"[QQOfficial] 分片顺序: {[int(p.get('index', 0)) for p in parts]}")
for part in parts:
try:
await _upload_one_part(part)
except Exception as e:
logger.error(f"[QQOfficial] 分片上传失败: {e}")
return None

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

分片上传实际上是串行执行的,未实现真正的并发上传

在当前的实现中,虽然解析了 concurrency 并创建了 semaphore,但在上传分片时却使用了 for part in parts: await _upload_one_part(part)。这导致分片是完全串行上传的,concurrencysemaphore 并没有起到并发控制的作用,极大限制了大文件的上传速度。

另外,如果直接改为并发上传,当前的 _read_file_chunk 处于 semaphore 外部,这会导致所有分片数据同时被读取到内存中,造成内存占用过高。

建议改进:

  1. 将读取分片和计算 MD5 的逻辑移入 async with semaphore: 内部,以限制内存中同时存在的块数据。
  2. 使用 asyncio.gather 并发执行分片上传任务,并在发生异常时及时取消其他未完成的任务。
  3. 此外,新引入的分片上传功能应当伴随相应的单元测试,请确保为其编写对应的单元测试以验证其正确性。
        async def _upload_one_part(part: dict):
            nonlocal completed_parts
            part_index = int(part.get("index", 0))
            presigned_url = part.get("presigned_url", "")
            part_block_size = int(part.get("block_size", 0)) or block_size
            offset = (part_index - 1) * block_size
            length = min(part_block_size, file_size - offset)

            if not presigned_url:
                logger.error(f"[QQOfficial] 分片 {part_index} 缺少 presigned_url")
                raise ValueError(f"Missing presigned_url for part {part_index}")

            async with semaphore:
                # 在信号量内部读取分片,防止并发读取过多分片导致内存溢出
                data = await asyncio.get_running_loop().run_in_executor(
                    None, self._read_file_chunk, file_source, offset, length
                )
                part_md5 = hashlib.md5(data).hexdigest()

                # PUT 到预签名 URL
                last_exc = None
                for attempt in range(self._CHUNKED_PART_UPLOAD_MAX_RETRIES + 1):
                    try:
                        async with aiohttp.ClientSession() as sess:
                            async with sess.put(
                                presigned_url,
                                data=data,
                                headers={"Content-Length": str(len(data))},
                                timeout=aiohttp.ClientTimeout(total=self._CHUNKED_PART_UPLOAD_TIMEOUT),
                            ) as resp:
                                resp.raise_for_status()
                                logger.info(f"[QQOfficial] PUT 分片 {part_index}/{len(parts)}: {resp.status} OK, size={len(data)}")
                                break
                    except Exception as exc:
                        last_exc = exc
                        if attempt < self._CHUNKED_PART_UPLOAD_MAX_RETRIES:
                            delay = 1.0 * (2 ** attempt)
                            logger.warning(f"[QQOfficial] PUT 分片 {part_index} 失败, 重试 {attempt+1}: {exc}")
                            await asyncio.sleep(delay)
                else:
                    logger.error(f"[QQOfficial] PUT 分片 {part_index} 最终失败: {last_exc}")
                    raise RuntimeError(f"Part {part_index} upload failed: {last_exc}")

                # 通知平台分片完成
                part_finish_block_size = int(part.get("block_size", 0)) or block_size
                part_finish_payload = {
                    "upload_id": upload_id,
                    "part_index": part_index,
                    "block_size": part_finish_block_size,
                    "md5": part_md5,
                }
                if openid:
                    finish_route = Route("POST", "/v2/users/{openid}/upload_part_finish", openid=openid)
                else:
                    finish_route = Route("POST", "/v2/groups/{group_openid}/upload_part_finish", group_openid=group_openid)

                logger.info(
                    f"[QQOfficial] part_finish 请求: part={part_index}, block_size={part_finish_block_size}, length={length}, md5={part_md5[:16]}..."
                )
                finish_start = asyncio.get_running_loop().time()
                finish_attempt = 0
                max_finish_retries = 5
                while True:
                    try:
                        await self.bot.api._http.request(finish_route, json=part_finish_payload)
                        break
                    except Exception as exc:
                        err_msg = str(exc)
                        finish_attempt += 1
                        elapsed = asyncio.get_running_loop().time() - finish_start
                        if "40093001" in err_msg:
                            if elapsed >= retry_timeout:
                                raise RuntimeError(f"part_finish timeout after {elapsed:.0f}s: {exc}") from exc
                            logger.debug(f"[QQOfficial] part_finish retryable(40093001), attempt {finish_attempt}")
                            await asyncio.sleep(self._CHUNKED_PART_FINISH_RETRY_INTERVAL)
                        else:
                            if finish_attempt >= max_finish_retries:
                                raise RuntimeError(f"part_finish failed after {finish_attempt} attempts: {exc}") from exc
                            delay = 1.0 * (2 ** (finish_attempt - 1))
                            logger.warning(f"[QQOfficial] part_finish 失败, 重试 {finish_attempt}/{max_finish_retries}: {exc}")
                            await asyncio.sleep(delay)

                completed_parts += 1
                logger.info(
                    f"[QQOfficial] 分片 {part_index}/{len(parts)} 完成 "
                    f"({completed_parts}/{len(parts)} done)"
                )

        # 并发上传所有分片
        logger.info(f"[QQOfficial] 开始并发上传分片,总数: {len(parts)}")
        tasks = [asyncio.create_task(_upload_one_part(part)) for part in parts]
        try:
            await asyncio.gather(*tasks)
        except Exception as e:
            logger.error(f"[QQOfficial] 分片上传失败: {e}")
            for t in tasks:
                if not t.done():
                    t.cancel()
            return None
References
  1. New functionality, such as handling attachments, should be accompanied by corresponding unit tests.

Comment on lines +708 to +743
async def _prepare_temp_file(file_source: str) -> tuple[str | None, str | None]:
"""将本地文件复制到临时目录(UUID 文件名),返回 (temp_path, temp_url)。
失败返回 (None, None)。
"""
if not _url_upload_available or not _media_upload_base_url:
return None, None

import shutil
import uuid as _uuid

ext = os.path.splitext(file_source)[1]
temp_name = f"{_uuid.uuid4().hex}{ext}"
temp_dir = os.path.join(os.getcwd(), "data", "media_upload_cache")
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, temp_name)

try:
await asyncio.to_thread(shutil.copy2, file_source, temp_path)
except Exception as e:
logger.warning(f"[QQOfficial] 创建临时文件失败: {e}")
return None, None

url = f"{_media_upload_base_url}/api/media/{temp_name}"
logger.info(f"[QQOfficial] 临时文件: {temp_path}")
logger.info(f"[QQOfficial] 下载 URL: {url}")
return temp_path, url

@staticmethod
def _cleanup_temp_file(temp_path: str | None) -> None:
"""删除临时文件。"""
if temp_path and os.path.exists(temp_path):
try:
os.unlink(temp_path)
logger.info(f"[QQOfficial] 已清理临时文件: {temp_path}")
except OSError as e:
logger.warning(f"[QQOfficial] 清理临时文件失败: {temp_path} ({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.

high

发现未使用的死代码 (Dead Code)

在当前的 PR 实现中,您最终采用了官方的分片上传 (Chunked Upload) 协议(通过 _chunked_upload 实现),这比通过 Web Server 生成临时 URL 让 QQ 服务器下载的方案更加优雅和安全。

但是,您在代码中残留了大量与“Web Server 临时 URL 方案”相关的死代码,这些代码在分片上传模式下完全没有被调用:

  1. _prepare_temp_file (708-733 行) 和 _cleanup_temp_file (735-743 行) 从未被调用。
  2. init_url_upload_probe (91-134 行) 探测并设置了 _url_upload_available,但该变量仅在未被调用的 _prepare_temp_file 中使用,因此整个探测逻辑也是多余的。
  3. dashboard/api/files.py 中的 /api/media/{filename} 路由也变成了死路由。

为了保持代码库的整洁 and 可维护性,建议将这些未使用的死代码、探测逻辑以及对应的 API 路由全部删除。

Comment on lines +853 to +856
# 启动时探测 URL 上传可用性(结果缓存,只执行一次)
from astrbot.core import astrbot_config
callback_base = astrbot_config.get("callback_api_base", "")
asyncio.get_event_loop().create_task(init_url_upload_probe(callback_base))

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

移除未使用的 URL 上传探测逻辑

由于“Web Server 临时 URL 方案”已被弃用并改为官方的“分片上传方案”,在启动时调用 init_url_upload_probe 探测 URL 可用性已无必要。建议将其移除以简化启动流程并减少不必要的后台任务。

Comment on lines +124 to +136
@legacy_router.get("/media/{filename}")
async def get_media_cache_file(filename: str):
"""按 UUID 文件名直接服务媒体缓存文件(无需 token,供 QQ 等平台下载)。"""
import re
if not re.match(r'^[0-9a-f]{32}\.[a-zA-Z0-9]+$', filename):
raise HTTPException(status_code=404)

media_cache_dir = os.path.join(os.getcwd(), "data", "media_upload_cache")
file_path = os.path.join(media_cache_dir, filename)
if not os.path.isfile(file_path):
raise HTTPException(status_code=404)

return FileResponse(file_path)

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

移除未使用的媒体缓存服务路由

由于“Web Server 临时 URL 方案”已被弃用,此 /media/{filename} 路由已无任何调用方,属于死代码。建议将其移除以保持 API 路由的整洁。

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:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants