From b86a2d4c4e41f8934bb4d09f7effce9a468a318e Mon Sep 17 00:00:00 2001 From: zhilv Date: Sat, 2 May 2026 15:28:54 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(*):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5=E4=B8=AD=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Bug 修复** - `message.py`: 批量发送时使用 `(index, msg)` 元组替代 `messages.index(msg)`,避免重复 dict 查找错误 - `message.py`: 多张图片逐张发送,不再静默丢弃后续图片 - `plugin.py`: API Key 日志只打印"已配置/自动生成",不再泄露密钥 - **潜在问题修复** - `message.py`: lambda 闭包添加默认参数绑定,防止循环变量捕获问题 - `upload.py`: 文件超限后消费剩余 multipart 数据,避免 reader 状态异常 - `config.py`: PORT 环境变量非法值容错,默认回退 8081 - `plugin.py`: cleanup task 保存引用,on_close 时正确取消,避免热重载泄漏 - **代码风格** - `message.py`: 无插值 f-string 改为普通字符串 - `upload.py`: read_chunk 硬编码提取为 CHUNK_SIZE 常量 --- config.py | 5 +- handlers/message.py | 349 ++++++++++++++++++++++++-------------------- handlers/upload.py | 7 +- plugin.py | 13 +- 4 files changed, 214 insertions(+), 160 deletions(-) diff --git a/config.py b/config.py index 43b492a..bb2083a 100644 --- a/config.py +++ b/config.py @@ -14,7 +14,10 @@ WEBHOOK_API_KEY: str = os.environ.get("WEBHOOK_API_KEY", "") or uuid.uuid4().hex # ── 网络 ───────────────────────────────────────────────────── HOST: str = os.environ.get("WEBHOOK_HOST", "0.0.0.0") -PORT: int = int(os.environ.get("WEBHOOK_PORT", "8081")) +try: + PORT: int = int(os.environ.get("WEBHOOK_PORT", "8081")) +except ValueError: + PORT = 8081 # ── 上传 ───────────────────────────────────────────────────── UPLOAD_DIR: Path = Path(os.environ.get("UPLOAD_DIR", str(Path(__file__).parent / "uploads"))) diff --git a/handlers/message.py b/handlers/message.py index 163cf2f..7717179 100644 --- a/handlers/message.py +++ b/handlers/message.py @@ -29,7 +29,7 @@ def _validate_message(msg: dict) -> str | None: if msg_type == "text" and not msg.get("msg"): return "missing required field: msg" if msg_type in {"image", "file", "video"} and not msg.get("url"): - return f"missing required field: url" + return "missing required field: url" return None @@ -50,28 +50,195 @@ async def _call_with_retry(coro_factory, logger, rid: str) -> None: raise Exception(f"qq api failed after {QQ_API_MAX_RETRIES} retries: {last_exc}") -async def webhook_handler(request: web.Request) -> web.Response: - """处理消息发送请求,支持单条和批量发送。 +async def _send_single(api, msg_type: str, url: str | None, msg_text: str, + group_id: str | None, user_id: str | None, + logger, rid: str) -> None: + """发送单条消息(非组合模式)。""" + filename = url.split("/")[-1] if url else None - 单条格式: - { - "group_id": "123", - "type": "text", - "msg": "hello" - } + if group_id: + match msg_type: + case "text": + await _call_with_retry( + lambda t=msg_text: api.qq.send_group_text(group_id=group_id, text=t), + logger, rid + ) + case "image": + await _call_with_retry( + lambda u=url: api.qq.send_group_image(group_id=group_id, image=u), + logger, rid + ) + case "file": + await _call_with_retry( + lambda u=url, n=filename: api.qq.send_group_file(group_id=group_id, file=u, name=n), + logger, rid + ) + case "video": + await _call_with_retry( + lambda u=url: api.qq.send_group_video(group_id=group_id, video=u), + logger, rid + ) + else: + match msg_type: + case "text": + await _call_with_retry( + lambda t=msg_text: api.qq.send_private_text(user_id=user_id, text=t), + logger, rid + ) + case "image": + await _call_with_retry( + lambda u=url: api.qq.send_private_image(user_id=user_id, image=u), + logger, rid + ) + case "file": + await _call_with_retry( + lambda u=url, n=filename: api.qq.send_private_file(user_id=user_id, file=u, name=n), + logger, rid + ) + case "video": + await _call_with_retry( + lambda u=url: api.qq.send_private_video(user_id=user_id, video=u), + logger, rid + ) - 批量格式(使用 post_group_msg 组合消息段): - { - "group_id": "123", - "messages": [ - {"type": "text", "msg": "hello"}, - {"type": "image", "url": "photo.jpg"} - ] - } - 批量发送时,text/image/video 会在一条消息里组合发送(框架自动拆分冲突段), - file 单独发送(因为 file 是独占段)。 +async def _send_batch(api, messages: list[dict], group_id: str | None, user_id: str | None, + logger, rid: str) -> list[dict]: + """批量发送消息。 + + - text/image/video 组合为一条消息(post_group_msg / post_private_msg) + - 多张图片拆成多条消息发送 + - file 单独发送(独占段) """ + results: list[dict] = [] + + # 分组:每条消息记录原始 index + combined_items: list[tuple[int, dict]] = [] # (index, msg) — text/image/video + file_items: list[tuple[int, dict]] = [] # (index, msg) — file + + for i, msg in enumerate(messages): + if msg.get("type") == "file": + file_items.append((i, msg)) + else: + combined_items.append((i, msg)) + + # 处理组合消息:按图片拆组,每组最多一张图片 + if combined_items: + # 收集所有 text、image、video + text_parts: list[str] = [] + image_items: list[tuple[int, dict]] = [] + video_url: str | None = None + video_index: int | None = None + combined_indices: list[int] = [] + + for idx, msg in combined_items: + msg_type = msg.get("type", "text") + if msg_type == "text": + text_parts.append(msg.get("msg", "")) + combined_indices.append(idx) + elif msg_type == "image": + image_items.append((idx, msg)) + elif msg_type == "video": + video_url = _resolve_url(msg.get("url", "")) + video_index = idx + + text = "\n".join(text_parts) if text_parts else None + + # 如果有视频,视频单独发(独占段会吞掉其他内容) + if video_url: + try: + if group_id: + await _call_with_retry( + lambda u=video_url: api.qq.post_group_msg( + group_id=group_id, video=u + ), + logger, rid + ) + else: + await _call_with_retry( + lambda u=video_url: api.qq.post_private_msg( + user_id=user_id, video=u + ), + logger, rid + ) + results.append({"index": video_index, "success": True}) + except Exception as exc: + logger.error(f"[{rid}] Video message failed: {exc}") + results.append({"index": video_index, "success": False, "error": str(exc)}) + + # 文本 + 第一张图片组合 + if text or image_items: + first_image_url = _resolve_url(image_items[0][1].get("url", "")) if image_items else None + text_image_indices = combined_indices + ([image_items[0][0]] if image_items else []) + + try: + if group_id: + await _call_with_retry( + lambda t=text, img=first_image_url: api.qq.post_group_msg( + group_id=group_id, text=t, image=img + ), + logger, rid + ) + else: + await _call_with_retry( + lambda t=text, img=first_image_url: api.qq.post_private_msg( + user_id=user_id, text=t, image=img + ), + logger, rid + ) + for idx in text_image_indices: + results.append({"index": idx, "success": True}) + except Exception as exc: + logger.error(f"[{rid}] Text+image message failed: {exc}") + for idx in text_image_indices: + results.append({"index": idx, "success": False, "error": str(exc)}) + + # 剩余图片逐张发送 + for img_idx, img_msg in image_items[1:]: + img_url = _resolve_url(img_msg.get("url", "")) + try: + if group_id: + await _call_with_retry( + lambda u=img_url: api.qq.send_group_image(group_id=group_id, image=u), + logger, rid + ) + else: + await _call_with_retry( + lambda u=img_url: api.qq.send_private_image(user_id=user_id, image=u), + logger, rid + ) + results.append({"index": img_idx, "success": True}) + except Exception as exc: + logger.error(f"[{rid}] Image message failed: {exc}") + results.append({"index": img_idx, "success": False, "error": str(exc)}) + + # 文件单独发送 + for idx, msg in file_items: + url = _resolve_url(msg.get("url", "")) + filename = url.split("/")[-1] + + try: + if group_id: + await _call_with_retry( + lambda u=url, n=filename: api.qq.send_group_file(group_id=group_id, file=u, name=n), + logger, rid + ) + else: + await _call_with_retry( + lambda u=url, n=filename: api.qq.send_private_file(user_id=user_id, file=u, name=n), + logger, rid + ) + results.append({"index": idx, "success": True}) + except Exception as exc: + logger.error(f"[{rid}] File message failed: {exc}") + results.append({"index": idx, "success": False, "error": str(exc)}) + + results.sort(key=lambda x: x["index"]) + return results + + +async def webhook_handler(request: web.Request) -> web.Response: + """处理消息发送请求,支持单条和批量发送。""" try: data = await request.json() except Exception: @@ -108,98 +275,13 @@ async def webhook_handler(request: web.Request) -> web.Response: if err: return error(f"messages[{i}]: {err}") - # 分组:text/image/video 可以组合,file 必须单独发 - combined_msgs: list[dict] = [] # text/image/video 组合 - file_msgs: list[dict] = [] # file 单独发 + try: + results = await _send_batch(api, messages, group_id, user_id, logger, rid) + except Exception as exc: + logger.error(f"[{rid}] Batch send failed: {exc}") + return error(f"batch send failed: {exc}", code=502, status=502) - for msg in messages: - if msg.get("type") == "file": - file_msgs.append(msg) - else: - combined_msgs.append(msg) - - results: list[dict] = [] - - # 发送组合消息(text + image + video) - if combined_msgs: - text_parts: list[str] = [] - image_urls: list[str] = [] - video_url: str | None = None - - for msg in combined_msgs: - msg_type = msg.get("type", "text") - if msg_type == "text": - text_parts.append(msg.get("msg", "")) - elif msg_type == "image": - image_urls.append(_resolve_url(msg.get("url", ""))) - elif msg_type == "video": - video_url = _resolve_url(msg.get("url", "")) - - text = "\n".join(text_parts) if text_parts else None - image = image_urls[0] if image_urls else None # post_group_msg 只支持单张图片 - - try: - if group_id: - await _call_with_retry( - lambda: api.qq.post_group_msg( - group_id=group_id, - text=text, - image=image, - video=video_url, - ), - logger, rid - ) - else: - await _call_with_retry( - lambda: api.qq.post_private_msg( - user_id=user_id, - text=text, - image=image, - video=video_url, - ), - logger, rid - ) - - # 所有组合消息标记为成功 - for i, msg in enumerate(messages): - if msg.get("type") != "file": - results.append({"index": i, "success": True}) - except Exception as exc: - logger.error(f"[{rid}] Combined message failed: {exc}") - for i, msg in enumerate(messages): - if msg.get("type") != "file": - results.append({"index": i, "success": False, "error": str(exc)}) - - # 发送文件消息(每个文件单独一条) - for msg in file_msgs: - idx = messages.index(msg) - url = _resolve_url(msg.get("url", "")) - filename = url.split("/")[-1] - - try: - if group_id: - await _call_with_retry( - lambda u=url, n=filename: api.qq.send_group_file( - group_id=group_id, file=u, name=n - ), - logger, rid - ) - else: - await _call_with_retry( - lambda u=url, n=filename: api.qq.send_private_file( - user_id=user_id, file=u, name=n - ), - logger, rid - ) - results.append({"index": idx, "success": True}) - except Exception as exc: - logger.error(f"[{rid}] File message failed: {exc}") - results.append({"index": idx, "success": False, "error": str(exc)}) - - # 按 index 排序结果 - results.sort(key=lambda x: x["index"]) success_count = sum(1 for r in results if r["success"]) - return ok(data={ "total": len(results), "success": success_count, @@ -208,7 +290,7 @@ async def webhook_handler(request: web.Request) -> web.Response: }) else: - # 单条模式(兼容旧格式) + # 单条模式 err = _validate_message(data) if err: return error(err) @@ -216,54 +298,9 @@ async def webhook_handler(request: web.Request) -> web.Response: msg_type = data.get("type", "text") url = _resolve_url(data.get("url", "")) if msg_type != "text" else None msg_text = data.get("msg", "") - filename = url.split("/")[-1] if url else None try: - if group_id: - match msg_type: - case "text": - await _call_with_retry( - lambda: api.qq.send_group_text(group_id=group_id, text=msg_text), - logger, rid - ) - case "image": - await _call_with_retry( - lambda: api.qq.send_group_image(group_id=group_id, image=url), - logger, rid - ) - case "file": - await _call_with_retry( - lambda: api.qq.send_group_file(group_id=group_id, file=url, name=filename), - logger, rid - ) - case "video": - await _call_with_retry( - lambda: api.qq.send_group_video(group_id=group_id, video=url), - logger, rid - ) - else: - match msg_type: - case "text": - await _call_with_retry( - lambda: api.qq.send_private_text(user_id=user_id, text=msg_text), - logger, rid - ) - case "image": - await _call_with_retry( - lambda: api.qq.send_private_image(user_id=user_id, image=url), - logger, rid - ) - case "file": - await _call_with_retry( - lambda: api.qq.send_private_file(user_id=user_id, file=url, name=filename), - logger, rid - ) - case "video": - await _call_with_retry( - lambda: api.qq.send_private_video(user_id=user_id, video=url), - logger, rid - ) - + await _send_single(api, msg_type, url, msg_text, group_id, user_id, logger, rid) return ok() except Exception as exc: logger.error(f"[{rid}] QQ API error: {exc}") diff --git a/handlers/upload.py b/handlers/upload.py index 282aa48..612fa2d 100644 --- a/handlers/upload.py +++ b/handlers/upload.py @@ -14,6 +14,8 @@ logger = logging.getLogger("webhook-plugin.upload") # 文件最大保留秒数(默认 24 小时) FILE_TTL_SECONDS: int = 24 * 60 * 60 +# 读取块大小 +CHUNK_SIZE: int = 65536 def _check_extension(filename: str) -> bool: @@ -58,11 +60,14 @@ async def upload_handler(request: web.Request) -> web.Response: chunks: list[bytes] = [] total_size = 0 while True: - chunk = await part.read_chunk(65536) + chunk = await part.read_chunk(CHUNK_SIZE) if not chunk: break total_size += len(chunk) if total_size > MAX_UPLOAD_SIZE: + # 消费剩余数据,避免 multipart reader 状态异常 + while await part.read_chunk(CHUNK_SIZE): + pass return error( f"file too large, max {MAX_UPLOAD_SIZE // (1024*1024)} MB", code=413, diff --git a/plugin.py b/plugin.py index ea8a0f1..87054f1 100644 --- a/plugin.py +++ b/plugin.py @@ -2,6 +2,7 @@ import asyncio import logging +import os from aiohttp import web from ncatbot.plugin import NcatBotPlugin @@ -24,14 +25,22 @@ class WebHookPlugin(NcatBotPlugin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._webhook_runner: web.AppRunner | None = None + self._cleanup_task: asyncio.Task | None = None async def on_load(self): self.logger.info("Webhook 插件已加载") - self.logger.info("WEBHOOK_API_KEY: %s", WEBHOOK_API_KEY) + self.logger.info("WEBHOOK_API_KEY: %s", "已配置" if os.environ.get("WEBHOOK_API_KEY") else "自动生成") asyncio.create_task(self._start_webhook()) - asyncio.create_task(self._cleanup_loop()) + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) async def on_close(self): + if self._cleanup_task is not None: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + self._cleanup_task = None await self._stop_webhook() self.logger.info("Webhook 插件已卸载")