diff --git a/danding_bot/plugins/danding_qqpush/README.md b/danding_bot/plugins/danding_qqpush/README.md index 5942858..841c774 100644 --- a/danding_bot/plugins/danding_qqpush/README.md +++ b/danding_bot/plugins/danding_qqpush/README.md @@ -6,7 +6,7 @@ - ✅ 通过 HTTP API 推送消息到指定 QQ 群 - ✅ 自动将文本渲染为图片,避免长文本刷屏 -- ✅ 支持 @指定 QQ 用户 +- ✅ 可选 @指定 QQ 用户;省略 `qq` 时直接发送群消息 - ✅ 使用 `#` 符号表示换行 - ✅ 基于 Token 的简单鉴权机制 - ✅ 支持中文文本渲染 @@ -64,7 +64,7 @@ nb run | -------- | ------ | ---- | ------------------------ | | token | path | 是 | 配置的 Token | | group_id | int | 是 | 接收消息的 QQ 群号 | -| qq | int | 是 | 被 @ 的 QQ 号 | +| qq | int | 否 | 可选的被 @ QQ 号;省略时不 @ 任何人 | | text | string | 是 | 通知文本(`#` 表示换行) | ### 请求示例 @@ -74,7 +74,6 @@ curl -X POST "http://localhost:8080/danding/qqpush/danding-8HkL9xQ2" \ -H "Content-Type: application/json" \ -d '{ "group_id": 123456789, - "qq": 987654321, "text": "系统告警#数据库连接失败#请立即处理" }' ``` @@ -87,7 +86,6 @@ import requests url = "http://localhost:8080/danding/qqpush/danding-8HkL9xQ2" data = { "group_id": 123456789, - "qq": 987654321, "text": "系统告警#数据库连接失败#请立即处理" } @@ -105,7 +103,7 @@ print(response.json()) "message": "推送成功", "data": { "group_id": 123456789, - "qq": 987654321, + "qq": null, "message_id": 12345 } } diff --git a/danding_bot/plugins/danding_qqpush/__init__.py b/danding_bot/plugins/danding_qqpush/__init__.py index 8d624df..1e86f18 100644 --- a/danding_bot/plugins/danding_qqpush/__init__.py +++ b/danding_bot/plugins/danding_qqpush/__init__.py @@ -18,8 +18,7 @@ __plugin_meta__ = PluginMetadata( 请求参数: { "group_id": 123456789, - "qq": 987654321, - "text": "系统告警#数据库连接失败#请立即处理" + "text": "系统告警#数据库连接失败#请立即处理" } 说明: diff --git a/danding_bot/plugins/danding_qqpush/api.py b/danding_bot/plugins/danding_qqpush/api.py index 82d0b04..0ad0f74 100644 --- a/danding_bot/plugins/danding_qqpush/api.py +++ b/danding_bot/plugins/danding_qqpush/api.py @@ -3,16 +3,16 @@ from fastapi import APIRouter, Request, HTTPException from pydantic import BaseModel import asyncio from typing import Optional -from nonebot import get_driver, logger +from nonebot import logger from .config import Config from .text_parser import TextParser from .image_render import ImageRenderer +from .sender import sender from .utils import validate_token - -# Module-level singleton: load font once, reuse across requests -_renderer: Optional['ImageRenderer'] = None -from .sender import sender + +# Module-level singleton: load font once, reuse across requests +_renderer: Optional['ImageRenderer'] = None def _get_renderer(config: Config) -> 'ImageRenderer': global _renderer @@ -36,8 +36,8 @@ class PushRequest(BaseModel): group_id: int """接收消息的 QQ 群号""" - qq: int - """被 @ 的 QQ 号""" + qq: Optional[int] = None + """可选的被 @ QQ 号;未传时仅发送群消息""" text: str """通知文本(# 表示换行)""" @@ -89,10 +89,7 @@ def create_routes(token: str, config: Config): if not data.group_id: raise HTTPException(status_code=400, detail="group_id 不能为空") - if not data.qq: - raise HTTPException(status_code=400, detail="qq 不能为空") - - if not data.text or not isinstance(data.text, str): + if not data.text or not isinstance(data.text, str): raise HTTPException(status_code=400, detail="text 不能为空且必须是字符串") # 2. 检查 Bot 是否在线 @@ -117,10 +114,10 @@ def create_routes(token: str, config: Config): logger.info("图片生成成功") # 5. 发送消息 - send_result = await sender.send_to_group( - group_id=data.group_id, - qq=data.qq, - image_base64=image_base64 + send_result = await sender.send_to_group( + group_id=data.group_id, + image_base64=image_base64, + qq=data.qq, ) if not send_result["success"]: diff --git a/danding_bot/plugins/danding_qqpush/sender.py b/danding_bot/plugins/danding_qqpush/sender.py index 9808d98..e587a92 100644 --- a/danding_bot/plugins/danding_qqpush/sender.py +++ b/danding_bot/plugins/danding_qqpush/sender.py @@ -42,14 +42,14 @@ class MessageSender: return None - async def _send_msg(self, group_id: int, qq: int, segment) -> dict: + async def _send_msg(self, group_id: int, segment, qq: Optional[int] = None) -> dict: """ - 内部通用发送方法(@用户 + 任意消息段) + 内部通用发送方法(可选 @ 用户 + 任意消息段) Args: group_id: 群号 - qq: 要 @ 的 QQ 号 - segment: MessageSegment 实例 + segment: MessageSegment 实例 + qq: 可选的要 @ QQ 号;未传时仅发送消息段 Returns: 发送结果字典 @@ -59,9 +59,10 @@ class MessageSender: raise ValueError("Bot 实例未设置,无法发送消息") try: - message = Message() - message.append(MessageSegment.at(qq)) - message.append(segment) + message = Message() + if qq is not None: + message.append(MessageSegment.at(qq)) + message.append(segment) result = await bot.call_api( "send_group_msg", @@ -73,16 +74,16 @@ class MessageSender: return {"success": True, "data": result, "message": "消息发送成功"} except Exception as e: - logger.warning(f"[QqPush] 消息发送失败 group={group_id} qq={qq}: {e}") + logger.warning(f"[QqPush] 消息发送失败 group={group_id} qq={qq}: {e}") return {"success": False, "error": str(e), "message": f"消息发送失败: {e}"} - async def send_to_group(self, group_id: int, qq: int, image_base64: str) -> dict: - """向指定群发送消息(@用户 + 图片)""" - return await self._send_msg(group_id, qq, MessageSegment.image(image_base64)) + async def send_to_group(self, group_id: int, image_base64: str, qq: Optional[int] = None) -> dict: + """向指定群发送图片,可选择 @ 成员。""" + return await self._send_msg(group_id, MessageSegment.image(image_base64), qq) - async def send_text_to_group(self, group_id: int, qq: int, text: str) -> dict: - """向指定群发送纯文本消息(@用户 + 文本)""" - return await self._send_msg(group_id, qq, MessageSegment.text(text)) + async def send_text_to_group(self, group_id: int, text: str, qq: Optional[int] = None) -> dict: + """向指定群发送纯文本消息,可选择 @ 成员。""" + return await self._send_msg(group_id, MessageSegment.text(text), qq) # 全局消息发送器实例 diff --git a/tests/test_danding_qqpush_sender.py b/tests/test_danding_qqpush_sender.py new file mode 100644 index 0000000..2b562d1 --- /dev/null +++ b/tests/test_danding_qqpush_sender.py @@ -0,0 +1,78 @@ +import asyncio +import importlib.util +import sys +import types +from pathlib import Path + + +def load_sender_module(monkeypatch): + """以最小 NoneBot 替身加载发送器,隔离真实 QQ 连接。""" + nonebot = types.ModuleType("nonebot") + nonebot.get_bots = lambda: {} + nonebot.logger = types.SimpleNamespace(warning=lambda *args, **kwargs: None) + adapter = types.ModuleType("nonebot.adapters.onebot.v11") + + class Bot: + pass + + class Message(list): + pass + + class MessageSegment: + @staticmethod + def at(qq): + return ("at", qq) + + @staticmethod + def image(image_base64): + return ("image", image_base64) + + @staticmethod + def text(text): + return ("text", text) + + adapter.Bot = Bot + adapter.Message = Message + adapter.MessageSegment = MessageSegment + monkeypatch.setitem(sys.modules, "nonebot", nonebot) + monkeypatch.setitem(sys.modules, "nonebot.adapters.onebot.v11", adapter) + + path = Path(__file__).resolve().parents[1] / "danding_bot" / "plugins" / "danding_qqpush" / "sender.py" + spec = importlib.util.spec_from_file_location("danding_qqpush_sender", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_send_image_without_qq_does_not_add_at_segment(monkeypatch): + module = load_sender_module(monkeypatch) + + class FakeBot: + async def call_api(self, api_name, **kwargs): + assert api_name == "send_group_msg" + assert kwargs["group_id"] == 621016172 + assert kwargs["message"] == [("image", "base64://announcement")] + return {"message_id": 1} + + sender = module.MessageSender() + sender.set_bot(FakeBot()) + + result = asyncio.run(sender.send_to_group(621016172, "base64://announcement")) + + assert result["success"] is True + + +def test_send_image_with_qq_keeps_at_segment(monkeypatch): + module = load_sender_module(monkeypatch) + + class FakeBot: + async def call_api(self, _api_name, **kwargs): + assert kwargs["message"] == [("at", 123456), ("image", "base64://announcement")] + return {"message_id": 1} + + sender = module.MessageSender() + sender.set_bot(FakeBot()) + + result = asyncio.run(sender.send_to_group(621016172, "base64://announcement", qq=123456)) + + assert result["success"] is True