-
-
Notifications
You must be signed in to change notification settings - Fork 2.5k
QQ官机适配器优化 #9204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
QQ官机适配器优化 #9204
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |||||||||||||||||||||||||
| from typing import cast | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import aiofiles | ||||||||||||||||||||||||||
| import aiohttp | ||||||||||||||||||||||||||
| import botpy | ||||||||||||||||||||||||||
| import botpy.errors | ||||||||||||||||||||||||||
| import botpy.message | ||||||||||||||||||||||||||
|
|
@@ -27,11 +28,8 @@ | |||||||||||||||||||||||||
| from astrbot.api.event import AstrMessageEvent, MessageChain | ||||||||||||||||||||||||||
| from astrbot.api.message_components import File, Image, Plain, Record, Video | ||||||||||||||||||||||||||
| from astrbot.api.platform import AstrBotMessage, PlatformMetadata | ||||||||||||||||||||||||||
| from astrbot.core.utils.media_utils import MediaResolver, file_uri_to_path, is_file_uri | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| class APIReturnNoneError(Exception): | ||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||
| from astrbot.core.utils.media_utils import MediaResolver, file_uri_to_path, is_file_uri | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _patch_qq_botpy_formdata() -> None: | ||||||||||||||||||||||||||
|
|
@@ -51,6 +49,10 @@ def _patch_qq_botpy_formdata() -> None: | |||||||||||||||||||||||||
| logger.debug("[QQOfficial] Skip botpy FormData patch.") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| class APIReturnNoneError(Exception): | ||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| _patch_qq_botpy_formdata() | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -72,7 +74,6 @@ def _qqofficial_retry(max_attempts: int = 5): | |||||||||||||||||||||||||
| reraise=True, | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| _QQOFFICIAL_SEND_API_ERRORS = ( | ||||||||||||||||||||||||||
| botpy.errors.ForbiddenError, | ||||||||||||||||||||||||||
| botpy.errors.MethodNotAllowedError, | ||||||||||||||||||||||||||
|
|
@@ -81,6 +82,55 @@ def _qqofficial_retry(max_attempts: int = 5): | |||||||||||||||||||||||||
| botpy.errors.ServerError, | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # URL 上传模式可用性缓存 | ||||||||||||||||||||||||||
| _url_upload_available: bool | None = None | ||||||||||||||||||||||||||
| _media_upload_base_url: str = "" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| async def init_url_upload_probe(media_upload_url: str = "") -> None: | ||||||||||||||||||||||||||
| """启动时探测媒体上传地址是否可达,结果缓存。 | ||||||||||||||||||||||||||
| 应在适配器启动时调用一次。同时清理上次遗留的临时文件。 | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
| global _url_upload_available, _media_upload_base_url | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # 等待 Web Server 启动完成 | ||||||||||||||||||||||||||
| await asyncio.sleep(10) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # 清理上次遗留的临时文件 | ||||||||||||||||||||||||||
| temp_dir = os.path.join(os.getcwd(), "data", "media_upload_cache") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if os.path.isdir(temp_dir): | ||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| import glob | ||||||||||||||||||||||||||
| stale = glob.glob(os.path.join(temp_dir, "*")) | ||||||||||||||||||||||||||
| for f in stale: | ||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| os.unlink(f) | ||||||||||||||||||||||||||
| except OSError: | ||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||
| if stale: | ||||||||||||||||||||||||||
| logger.info(f"[QQOfficial] 清理遗留临时文件: {len(stale)} 个") | ||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if not media_upload_url: | ||||||||||||||||||||||||||
| _url_upload_available = False | ||||||||||||||||||||||||||
| logger.info("[QQOfficial] 未配置 media_upload_url,URL 上传模式不可用,将使用 Base64") | ||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| _media_upload_base_url = media_upload_url.rstrip('/') | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for attempt in range(3): | ||||||||||||||||||||||||||
| _url_upload_available = await QQOfficialMessageEvent._probe_temp_url_reachable(_media_upload_base_url) | ||||||||||||||||||||||||||
| if _url_upload_available: | ||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||
| if attempt < 2: | ||||||||||||||||||||||||||
| await asyncio.sleep(3) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if _url_upload_available: | ||||||||||||||||||||||||||
| logger.info(f"[QQOfficial] URL 上传模式已启用 (callback_api_base={_media_upload_base_url})") | ||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||
| logger.warning(f"[QQOfficial] callback_api_base 不可达 ({_media_upload_base_url}),回退 Base64 模式") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| class QQOfficialMessageEvent(AstrMessageEvent): | ||||||||||||||||||||||||||
| MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown" | ||||||||||||||||||||||||||
|
|
@@ -502,7 +552,6 @@ async def _send_with_markdown_fallback( | |||||||||||||||||||||||||
| logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err) | ||||||||||||||||||||||||||
| if payload.get("msg_id"): | ||||||||||||||||||||||||||
| fallback_payload = payload.copy() | ||||||||||||||||||||||||||
| fallback_payload.pop("msg_id", None) | ||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| ret = await send_func(fallback_payload) | ||||||||||||||||||||||||||
|
Comment on lines
553
to
556
|
||||||||||||||||||||||||||
| logger.info("[QQOfficial] 使用主动发送接口发送成功。") | ||||||||||||||||||||||||||
|
Comment on lines
553
to
557
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 在重构中移除了
Suggested change
|
||||||||||||||||||||||||||
|
|
@@ -574,27 +623,19 @@ async def _do_upload(): | |||||||||||||||||||||||||
| route = Route( | ||||||||||||||||||||||||||
| "POST", "/v2/users/{openid}/files", openid=kwargs["openid"] | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| return await self.bot.api._http.request(route, json=payload) | ||||||||||||||||||||||||||
| elif "group_openid" in kwargs: | ||||||||||||||||||||||||||
| payload["group_openid"] = kwargs["group_openid"] | ||||||||||||||||||||||||||
| route = Route( | ||||||||||||||||||||||||||
| "POST", | ||||||||||||||||||||||||||
| "/v2/groups/{group_openid}/files", | ||||||||||||||||||||||||||
| group_openid=kwargs["group_openid"], | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| return await self.bot.api._http.request(route, json=payload) | ||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||
| raise ValueError("Invalid upload parameters") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| result = await self.bot.api._http.request(route, json=payload) | ||||||||||||||||||||||||||
| if result is None: | ||||||||||||||||||||||||||
| err_msg = "上传图片API返回None,触发重试" | ||||||||||||||||||||||||||
| raise APIReturnNoneError(err_msg) | ||||||||||||||||||||||||||
| return result | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| result = await _do_upload() | ||||||||||||||||||||||||||
| except APIReturnNoneError: | ||||||||||||||||||||||||||
| logger.warning(f"上传图片API返回None,共尝试5次后放弃: {payload}") | ||||||||||||||||||||||||||
| raise | ||||||||||||||||||||||||||
| result = await _do_upload() | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if not isinstance(result, dict): | ||||||||||||||||||||||||||
| raise RuntimeError( | ||||||||||||||||||||||||||
|
|
@@ -607,6 +648,58 @@ async def _do_upload(): | |||||||||||||||||||||||||
| ttl=result.get("ttl", 0), | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||||
| async def _probe_temp_url_reachable(base_url: str) -> bool: | ||||||||||||||||||||||||||
| """探测 AstrBot Web Server 是否对外可达(GET 面板根路径)。""" | ||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| async with aiohttp.ClientSession() as session: | ||||||||||||||||||||||||||
| async with session.get( | ||||||||||||||||||||||||||
| base_url, | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| timeout=aiohttp.ClientTimeout(total=5), | ||||||||||||||||||||||||||
| allow_redirects=True, | ||||||||||||||||||||||||||
| ) as resp: | ||||||||||||||||||||||||||
| return resp.status == 200 | ||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||||
| shutil.copy2(file_source, temp_path) | ||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||
| logger.warning(f"[QQOfficial] 创建临时文件失败: {e}") | ||||||||||||||||||||||||||
| return None, None | ||||||||||||||||||||||||||
|
Comment on lines
+682
to
+686
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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})") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| async def upload_group_and_c2c_media( | ||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||
| file_source: str, | ||||||||||||||||||||||||||
|
|
@@ -615,24 +708,16 @@ async def upload_group_and_c2c_media( | |||||||||||||||||||||||||
| file_name: str | None = None, | ||||||||||||||||||||||||||
| **kwargs, | ||||||||||||||||||||||||||
| ) -> Media | None: | ||||||||||||||||||||||||||
| """上传媒体文件""" | ||||||||||||||||||||||||||
| # 构建基础payload | ||||||||||||||||||||||||||
| """上传媒体文件。 | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 视频/文件:URL 上传优先,失败回退 Base64。 | ||||||||||||||||||||||||||
| 音频:直接 Base64。 | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
| payload: dict = {"file_type": file_type, "srv_send_msg": srv_send_msg} | ||||||||||||||||||||||||||
| if file_name: | ||||||||||||||||||||||||||
| payload["file_name"] = file_name | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # 处理文件数据 | ||||||||||||||||||||||||||
| if os.path.exists(file_source): | ||||||||||||||||||||||||||
| # 读取本地文件 | ||||||||||||||||||||||||||
| async with aiofiles.open(file_source, "rb") as f: | ||||||||||||||||||||||||||
| file_content = await f.read() | ||||||||||||||||||||||||||
| # use base64 encode | ||||||||||||||||||||||||||
| payload["file_data"] = base64.b64encode(file_content).decode("utf-8") | ||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||
| # 使用URL | ||||||||||||||||||||||||||
| payload["url"] = file_source | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # 添加接收者信息和确定路由 | ||||||||||||||||||||||||||
| route: Route | None = None | ||||||||||||||||||||||||||
| if "openid" in kwargs: | ||||||||||||||||||||||||||
| payload["openid"] = kwargs["openid"] | ||||||||||||||||||||||||||
| route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"]) | ||||||||||||||||||||||||||
|
|
@@ -646,34 +731,66 @@ async def upload_group_and_c2c_media( | |||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @_qqofficial_retry() | ||||||||||||||||||||||||||
| async def _do_upload(): | ||||||||||||||||||||||||||
| result = await self.bot.api._http.request(route, json=payload) | ||||||||||||||||||||||||||
| if result is None: | ||||||||||||||||||||||||||
| err_msg = "上传文件API返回None,触发重试" | ||||||||||||||||||||||||||
| raise APIReturnNoneError(err_msg) | ||||||||||||||||||||||||||
| return result | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| temp_path: str | None = None | ||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| result = await _do_upload() | ||||||||||||||||||||||||||
| # 视频/文件:尝试 URL 上传 | ||||||||||||||||||||||||||
| if os.path.exists(file_source) and file_type in (self.VIDEO_FILE_TYPE, self.FILE_FILE_TYPE): | ||||||||||||||||||||||||||
| temp_path, temp_url = await self._prepare_temp_file(file_source) | ||||||||||||||||||||||||||
| if temp_url: | ||||||||||||||||||||||||||
| payload["url"] = temp_url | ||||||||||||||||||||||||||
| url_result = None | ||||||||||||||||||||||||||
| for _attempt in range(3): | ||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||
| url_result = await self.bot.api._http.request(route, json=payload) | ||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||
| logger.warning(f"[QQOfficial] URL 上传失败 ({e}),重试...") | ||||||||||||||||||||||||||
| await asyncio.sleep(3) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if url_result and isinstance(url_result, dict): | ||||||||||||||||||||||||||
| logger.info(f"[QQOfficial] URL 上传成功: {file_source}") | ||||||||||||||||||||||||||
| self._cleanup_temp_file(temp_path) | ||||||||||||||||||||||||||
| temp_path = None | ||||||||||||||||||||||||||
| return Media( | ||||||||||||||||||||||||||
| file_uuid=url_result["file_uuid"], | ||||||||||||||||||||||||||
| file_info=url_result["file_info"], | ||||||||||||||||||||||||||
| ttl=url_result.get("ttl", 0), | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| logger.warning("[QQOfficial] URL 上传 3 次均失败,回退 Base64") | ||||||||||||||||||||||||||
| # 保留临时文件供排查,启动时兜底清理 | ||||||||||||||||||||||||||
| temp_path = None | ||||||||||||||||||||||||||
| payload.pop("url", None) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # Base64 路径(音频、URL 不可用、或 URL 上传失败回退) | ||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @_qqofficial_retry() | ||||||||||||||||||||||||||
| async def _do_upload(): | ||||||||||||||||||||||||||
|
Comment on lines
+773
to
+774
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 与图片上传类似,这里在重构
Suggested change
|
||||||||||||||||||||||||||
| return await self.bot.api._http.request(route, json=payload) | ||||||||||||||||||||||||||
|
Comment on lines
+773
to
+775
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| result = await _do_upload() | ||||||||||||||||||||||||||
| if result: | ||||||||||||||||||||||||||
| if not isinstance(result, dict): | ||||||||||||||||||||||||||
| logger.error(f"上传文件响应格式错误: {result}") | ||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return Media( | ||||||||||||||||||||||||||
| file_uuid=result["file_uuid"], | ||||||||||||||||||||||||||
| file_info=result["file_info"], | ||||||||||||||||||||||||||
| ttl=result.get("ttl", 0), | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| except APIReturnNoneError: | ||||||||||||||||||||||||||
| logger.warning(f"上传文件API返回None,共尝试5次后放弃: {file_source}") | ||||||||||||||||||||||||||
| except (botpy.errors.ServerError, botpy.errors.SequenceNumberError): | ||||||||||||||||||||||||||
| logger.error(f"上传媒体文件失败,共尝试5次后放弃: {file_source}") | ||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||
| logger.error(f"上传请求错误: {e}") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # URL 上传成功时 temp_path 已返回保留,此处无需清理。 | ||||||||||||||||||||||||||
| # URL 上传失败时已在上方清理。残留文件由启动时的 init_url_upload_probe() 兜底清理。 | ||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| async def post_c2c_message( | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (complexity): Consider encapsulating the URL-upload behavior into a dedicated strategy with shared retry helpers and smaller upload helpers to make
upload_group_and_c2c_mediaand temp-file handling easier to understand and maintain.You can keep all behavior while reducing complexity by:
upload_group_and_c2c_mediainto smaller helpers1. Encapsulate URL-upload state instead of globals
Move
_url_upload_available/_media_upload_base_urland probe/prepare/cleanup into a small helper or intoQQOfficialMessageEventas a cohesive unit. This keeps related logic together and avoids scattered globals.For example, extract a minimal helper:
Then hang an instance off
QQOfficialMessageEvent(or the adapter) instead of using module-level globals:This keeps state and behavior together and makes the lifecycle clearer.
2. Unify retry behavior for URL and Base64
You currently have:
_qqofficial_retrydecorator for base64 path.for _attempt in range(3)loop for URL path.You can reuse
_qqofficial_retryby parameterizing max attempts and keeping URL-specific logic in a small helper:Then in
upload_group_and_c2c_media:This removes the manual
forloop and aligns retries with the rest of the API.3. Split
upload_group_and_c2c_mediainto focused helpersRight now
upload_group_and_c2c_mediadecides route, temp-file URL path with retries, base64 fallback, and result-to-Media. You can make the top-level method linear by moving responsibilities into small functions:Helper implementations stay small and focused:
This keeps
upload_group_and_c2c_mediareadable and makes the URL/base64 behavior easier to reason about.4. Make temp-file lifecycle rules explicit
Instead of relying on comments and implicit “leave some files, cleanup on startup”, codify the policy with a flag or clear branch. For example:
Then call
self._cleanup_temp_file(temp_path, on_success=True)on successful URL upload, andon_success=Falseon failure. This makes behavior explicit and reduces subtlety around “无需清理 / 兜底清理”.