fix(qqpush): 支持无@群图片推送

This commit is contained in:
2026-07-30 20:54:43 +08:00
parent 1118c0a6e1
commit f8e9544fd6
5 changed files with 109 additions and 36 deletions

View File

@@ -6,7 +6,7 @@
- ✅ 通过 HTTP API 推送消息到指定 QQ 群 - ✅ 通过 HTTP API 推送消息到指定 QQ 群
- ✅ 自动将文本渲染为图片,避免长文本刷屏 - ✅ 自动将文本渲染为图片,避免长文本刷屏
-支持 @指定 QQ 用户 -可选 @指定 QQ 用户;省略 `qq` 时直接发送群消息
- ✅ 使用 `#` 符号表示换行 - ✅ 使用 `#` 符号表示换行
- ✅ 基于 Token 的简单鉴权机制 - ✅ 基于 Token 的简单鉴权机制
- ✅ 支持中文文本渲染 - ✅ 支持中文文本渲染
@@ -64,7 +64,7 @@ nb run
| -------- | ------ | ---- | ------------------------ | | -------- | ------ | ---- | ------------------------ |
| token | path | 是 | 配置的 Token | | token | path | 是 | 配置的 Token |
| group_id | int | 是 | 接收消息的 QQ 群号 | | group_id | int | 是 | 接收消息的 QQ 群号 |
| qq | int | | 被 @ QQ 号 | | qq | int | | 可选的被 @ QQ 号;省略时不 @ 任何人 |
| text | string | 是 | 通知文本(`#` 表示换行) | | text | string | 是 | 通知文本(`#` 表示换行) |
### 请求示例 ### 请求示例
@@ -74,7 +74,6 @@ curl -X POST "http://localhost:8080/danding/qqpush/danding-8HkL9xQ2" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"group_id": 123456789, "group_id": 123456789,
"qq": 987654321,
"text": "系统告警#数据库连接失败#请立即处理" "text": "系统告警#数据库连接失败#请立即处理"
}' }'
``` ```
@@ -87,7 +86,6 @@ import requests
url = "http://localhost:8080/danding/qqpush/danding-8HkL9xQ2" url = "http://localhost:8080/danding/qqpush/danding-8HkL9xQ2"
data = { data = {
"group_id": 123456789, "group_id": 123456789,
"qq": 987654321,
"text": "系统告警#数据库连接失败#请立即处理" "text": "系统告警#数据库连接失败#请立即处理"
} }
@@ -105,7 +103,7 @@ print(response.json())
"message": "推送成功", "message": "推送成功",
"data": { "data": {
"group_id": 123456789, "group_id": 123456789,
"qq": 987654321, "qq": null,
"message_id": 12345 "message_id": 12345
} }
} }

View File

@@ -18,7 +18,6 @@ __plugin_meta__ = PluginMetadata(
请求参数: 请求参数:
{ {
"group_id": 123456789, "group_id": 123456789,
"qq": 987654321,
"text": "系统告警#数据库连接失败#请立即处理" "text": "系统告警#数据库连接失败#请立即处理"
} }

View File

@@ -3,16 +3,16 @@ from fastapi import APIRouter, Request, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
import asyncio import asyncio
from typing import Optional from typing import Optional
from nonebot import get_driver, logger from nonebot import logger
from .config import Config from .config import Config
from .text_parser import TextParser from .text_parser import TextParser
from .image_render import ImageRenderer from .image_render import ImageRenderer
from .sender import sender
from .utils import validate_token from .utils import validate_token
# Module-level singleton: load font once, reuse across requests # Module-level singleton: load font once, reuse across requests
_renderer: Optional['ImageRenderer'] = None _renderer: Optional['ImageRenderer'] = None
from .sender import sender
def _get_renderer(config: Config) -> 'ImageRenderer': def _get_renderer(config: Config) -> 'ImageRenderer':
global _renderer global _renderer
@@ -36,8 +36,8 @@ class PushRequest(BaseModel):
group_id: int group_id: int
"""接收消息的 QQ 群号""" """接收消息的 QQ 群号"""
qq: int qq: Optional[int] = None
"""被 @ QQ 号""" """可选的被 @ QQ 号;未传时仅发送群消息"""
text: str text: str
"""通知文本(# 表示换行)""" """通知文本(# 表示换行)"""
@@ -89,9 +89,6 @@ def create_routes(token: str, config: Config):
if not data.group_id: if not data.group_id:
raise HTTPException(status_code=400, detail="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 不能为空且必须是字符串") raise HTTPException(status_code=400, detail="text 不能为空且必须是字符串")
@@ -119,8 +116,8 @@ def create_routes(token: str, config: Config):
# 5. 发送消息 # 5. 发送消息
send_result = await sender.send_to_group( send_result = await sender.send_to_group(
group_id=data.group_id, group_id=data.group_id,
image_base64=image_base64,
qq=data.qq, qq=data.qq,
image_base64=image_base64
) )
if not send_result["success"]: if not send_result["success"]:

View File

@@ -42,14 +42,14 @@ class MessageSender:
return None 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: Args:
group_id: 群号 group_id: 群号
qq: 要 @ 的 QQ 号
segment: MessageSegment 实例 segment: MessageSegment 实例
qq: 可选的要 @ QQ 号;未传时仅发送消息段
Returns: Returns:
发送结果字典 发送结果字典
@@ -60,7 +60,8 @@ class MessageSender:
try: try:
message = Message() message = Message()
message.append(MessageSegment.at(qq)) if qq is not None:
message.append(MessageSegment.at(qq))
message.append(segment) message.append(segment)
result = await bot.call_api( result = await bot.call_api(
@@ -76,13 +77,13 @@ class MessageSender:
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}"} return {"success": False, "error": str(e), "message": f"消息发送失败: {e}"}
async def send_to_group(self, group_id: int, qq: int, image_base64: str) -> dict: async def send_to_group(self, group_id: int, image_base64: str, qq: Optional[int] = None) -> dict:
"""向指定群发送消息(@用户 + 图片)""" """向指定群发送图片,可选择 @ 成员。"""
return await self._send_msg(group_id, qq, MessageSegment.image(image_base64)) 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: async def send_text_to_group(self, group_id: int, text: str, qq: Optional[int] = None) -> dict:
"""向指定群发送纯文本消息@用户 + 文本)""" """向指定群发送纯文本消息,可选择 @ 成员。"""
return await self._send_msg(group_id, qq, MessageSegment.text(text)) return await self._send_msg(group_id, MessageSegment.text(text), qq)
# 全局消息发送器实例 # 全局消息发送器实例

View File

@@ -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