fix(qqpush): 支持无@群图片推送
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ __plugin_meta__ = PluginMetadata(
|
||||
请求参数:
|
||||
{
|
||||
"group_id": 123456789,
|
||||
"qq": 987654321,
|
||||
"text": "系统告警#数据库连接失败#请立即处理"
|
||||
"text": "系统告警#数据库连接失败#请立即处理"
|
||||
}
|
||||
|
||||
说明:
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# 全局消息发送器实例
|
||||
|
||||
78
tests/test_danding_qqpush_sender.py
Normal file
78
tests/test_danding_qqpush_sender.py
Normal 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
|
||||
Reference in New Issue
Block a user