fix(qqpush): 支持无@群图片推送
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ __plugin_meta__ = PluginMetadata(
|
|||||||
请求参数:
|
请求参数:
|
||||||
{
|
{
|
||||||
"group_id": 123456789,
|
"group_id": 123456789,
|
||||||
"qq": 987654321,
|
|
||||||
"text": "系统告警#数据库连接失败#请立即处理"
|
"text": "系统告警#数据库连接失败#请立即处理"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]:
|
||||||
|
|||||||
@@ -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,6 +60,7 @@ class MessageSender:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
message = Message()
|
message = Message()
|
||||||
|
if qq is not None:
|
||||||
message.append(MessageSegment.at(qq))
|
message.append(MessageSegment.at(qq))
|
||||||
message.append(segment)
|
message.append(segment)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
# 全局消息发送器实例
|
# 全局消息发送器实例
|
||||||
|
|||||||
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