功能:通过 HTTP API 实现 Danding_QqPush 插件,用于 QQ 群通知
- 增加了通过外部 HTTP API 向 QQ 群组发送消息的核心功能。 - 实现了对长文本消息的图片渲染,以避免被认定为垃圾信息。 - 支持在消息中提及特定的 QQ 用户。 - 创建了用于 API 令牌和图片渲染设置的配置选项。 - 开发了一个测试脚本以验证 API 功能。 - 对现有代码进行了重构,以提高组织性和可维护性。
This commit is contained in:
@@ -25,8 +25,8 @@ plugin_config = get_plugin_config(Config)
|
||||
async def handle_api_result(
|
||||
bot: Bot, exception: Optional[Exception], api: str, data: Dict[str, Any], result: Any
|
||||
):
|
||||
"""拦截 send_msg API 调用,监控发出的消息"""
|
||||
if api != "send_msg" or exception:
|
||||
"""拦截 send_msg 和 send_group_msg API 调用,监控发出的消息"""
|
||||
if api not in ["send_msg", "send_group_msg"] or exception:
|
||||
return
|
||||
|
||||
# 获取消息 ID
|
||||
|
||||
219
danding_bot/plugins/danding_qqpush/README.md
Normal file
219
danding_bot/plugins/danding_qqpush/README.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Danding_QqPush 插件
|
||||
|
||||
用于通过外部 HTTP API 向 QQ 群定向推送通知的 NoneBot 插件。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 通过 HTTP API 推送消息到指定 QQ 群
|
||||
- ✅ 自动将文本渲染为图片,避免长文本刷屏
|
||||
- ✅ 支持 @指定 QQ 用户
|
||||
- ✅ 使用 `#` 符号表示换行
|
||||
- ✅ 基于 Token 的简单鉴权机制
|
||||
- ✅ 支持中文文本渲染
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
danding_qqpush/
|
||||
├── __init__.py # 插件初始化模块
|
||||
├── config.py # 配置模块
|
||||
├── api.py # FastAPI 路由模块
|
||||
├── text_parser.py # 文本处理模块
|
||||
├── image_render.py # 图片生成模块
|
||||
├── sender.py # 消息发送模块
|
||||
└── utils.py # 工具函数模块
|
||||
```
|
||||
|
||||
## 安装与配置
|
||||
|
||||
### 1. 确保依赖已安装
|
||||
|
||||
```bash
|
||||
pip install pillow
|
||||
```
|
||||
|
||||
### 2. 配置 Token
|
||||
|
||||
在 `.env` 文件或 NoneBot 配置文件中添加:
|
||||
|
||||
```env
|
||||
DANDING_QQPUSH_TOKEN = "your-custom-token-here"
|
||||
```
|
||||
|
||||
如果不配置,默认使用 `danding-8HkL9xQ2`。
|
||||
|
||||
### 3. 启动 NoneBot
|
||||
|
||||
```bash
|
||||
nb run
|
||||
```
|
||||
|
||||
插件会自动加载,并在日志中显示注册的 API 路径。
|
||||
|
||||
## API 使用说明
|
||||
|
||||
### 接口信息
|
||||
|
||||
- **方法**: `POST`
|
||||
- **路径**: `/danding/qqpush/{token}`
|
||||
- **Content-Type**: `application/json`
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
| -------- | ------ | ---- | ------------------------ |
|
||||
| token | path | 是 | 配置的 Token |
|
||||
| group_id | int | 是 | 接收消息的 QQ 群号 |
|
||||
| qq | int | 是 | 被 @ 的 QQ 号 |
|
||||
| text | string | 是 | 通知文本(`#` 表示换行) |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/danding/qqpush/danding-8HkL9xQ2" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"group_id": 123456789,
|
||||
"qq": 987654321,
|
||||
"text": "系统告警#数据库连接失败#请立即处理"
|
||||
}'
|
||||
```
|
||||
|
||||
### Python 请求示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "http://localhost:8080/danding/qqpush/danding-8HkL9xQ2"
|
||||
data = {
|
||||
"group_id": 123456789,
|
||||
"qq": 987654321,
|
||||
"text": "系统告警#数据库连接失败#请立即处理"
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
### 响应示例
|
||||
|
||||
**成功响应** (200):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "推送成功",
|
||||
"data": {
|
||||
"group_id": 123456789,
|
||||
"qq": 987654321,
|
||||
"message_id": 12345
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应** (400):
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "group_id 不能为空"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应** (403):
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Token 验证失败"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应** (500):
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Bot 未连接,请检查机器人状态"
|
||||
}
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
可以在配置文件中自定义以下选项:
|
||||
|
||||
```python
|
||||
class Config(BaseModel):
|
||||
Token: str = "danding-8HkL9xQ2"
|
||||
"""API 访问 Token"""
|
||||
|
||||
ImageWidth: int = 800
|
||||
"""图片宽度(像素)"""
|
||||
|
||||
ImageFontSize: int = 24
|
||||
"""字体大小(像素)"""
|
||||
|
||||
ImagePadding: int = 30
|
||||
"""图片内边距(像素)"""
|
||||
|
||||
ImageLineSpacing: float = 1.4
|
||||
"""行距倍数"""
|
||||
|
||||
ImageBgColor: tuple = (252, 252, 252)
|
||||
"""图片背景颜色 (R, G, B)"""
|
||||
|
||||
ImageTextColor: tuple = (0, 0, 0)
|
||||
"""文本颜色 (R, G, B)"""
|
||||
|
||||
MaxTextLength: int = 2000
|
||||
"""最大文本长度(字符数)"""
|
||||
|
||||
FontPaths: list = [...]
|
||||
"""字体文件路径列表"""
|
||||
```
|
||||
|
||||
## 文本格式说明
|
||||
|
||||
- 使用 `#` 符号表示换行
|
||||
- 示例:`第一行#第二行#第三行` 会被渲染为三行文本
|
||||
- 超过最大长度的文本会被自动截断
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **安全性**: Token 泄露只影响推送能力,无账号风险,但建议定期更换
|
||||
2. **Bot 状态**: 确保 Bot 已连接,否则会返回 500 错误
|
||||
3. **群权限**: 确保 Bot 在目标群中有发送消息的权限
|
||||
4. **字体支持**: 插件会自动尝试加载系统中的中文字体
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题:启动时显示 "未找到可用的 Bot 实例"
|
||||
|
||||
**说明**: 这是正常现象。插件加载时 Bot 可能还未连接,API 调用时会动态获取 Bot 实例。
|
||||
|
||||
### 问题:返回 "Bot 未连接"
|
||||
|
||||
**解决方案**:
|
||||
- 检查 NoneBot 是否正常启动
|
||||
- 检查 OneBot V11 连接状态
|
||||
- 确认 Bot 已成功连接到 QQ 服务器
|
||||
|
||||
### 问题:图片显示乱码
|
||||
|
||||
**解决方案**:
|
||||
- 检查系统是否安装了中文字体
|
||||
- 在 `FontPaths` 配置中添加正确的字体路径
|
||||
|
||||
### 问题:消息发送失败
|
||||
|
||||
**解决方案**:
|
||||
- 检查 Bot 是否在目标群中
|
||||
- 检查 Bot 是否有发送消息的权限
|
||||
- 查看日志中的详细错误信息
|
||||
|
||||
## 测试
|
||||
|
||||
使用提供的测试脚本进行测试:
|
||||
|
||||
```bash
|
||||
python test_qqpush.py
|
||||
```
|
||||
|
||||
修改脚本中的 `group_id` 和 `qq` 为实际值。
|
||||
69
danding_bot/plugins/danding_qqpush/__init__.py
Normal file
69
danding_bot/plugins/danding_qqpush/__init__.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Danding_QqPush 插件初始化模块"""
|
||||
from nonebot import get_driver, get_bots
|
||||
from nonebot.log import logger
|
||||
from nonebot.plugin import PluginMetadata
|
||||
|
||||
from .config import Config
|
||||
from .api import create_routes
|
||||
from .sender import sender
|
||||
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="danding_qqpush",
|
||||
description="通过外部 HTTP API 向 QQ 群定向推送通知",
|
||||
usage="""
|
||||
API 接口:
|
||||
POST /danding/qqpush/{token}
|
||||
|
||||
请求参数:
|
||||
{
|
||||
"group_id": 123456789,
|
||||
"qq": 987654321,
|
||||
"text": "系统告警#数据库连接失败#请立即处理"
|
||||
}
|
||||
|
||||
说明:
|
||||
- text 中的 # 表示换行
|
||||
- 消息会自动渲染为图片并发送到指定群
|
||||
""",
|
||||
config=Config,
|
||||
)
|
||||
|
||||
|
||||
# 加载配置
|
||||
plugin_config = Config.parse_obj(get_driver().config)
|
||||
|
||||
|
||||
def register_routes():
|
||||
"""注册 FastAPI 路由"""
|
||||
driver = get_driver()
|
||||
|
||||
# 创建并注册路由
|
||||
routes = create_routes(plugin_config.Token, plugin_config)
|
||||
driver.server_app.include_router(routes)
|
||||
|
||||
logger.info(f"[Danding_QqPush] API 路由已注册: /danding/qqpush/{plugin_config.Token}")
|
||||
|
||||
|
||||
def init_bot():
|
||||
"""初始化 Bot 实例"""
|
||||
try:
|
||||
bots = get_bots()
|
||||
if bots:
|
||||
# 获取第一个可用的 Bot
|
||||
bot = list(bots.values())[0]
|
||||
sender.set_bot(bot)
|
||||
logger.info(f"[Danding_QqPush] Bot 已连接: {bot.self_id}")
|
||||
else:
|
||||
logger.warning("[Danding_QqPush] 未找到可用的 Bot 实例")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Danding_QqPush] 初始化 Bot 失败: {str(e)}")
|
||||
|
||||
|
||||
# 插件加载时注册路由并初始化 Bot
|
||||
try:
|
||||
register_routes()
|
||||
init_bot()
|
||||
logger.info("[Danding_QqPush] 插件加载成功")
|
||||
except Exception as e:
|
||||
logger.error(f"[Danding_QqPush] 插件加载失败: {str(e)}")
|
||||
142
danding_bot/plugins/danding_qqpush/api.py
Normal file
142
danding_bot/plugins/danding_qqpush/api.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""API 接口模块 - FastAPI 路由定义"""
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from nonebot import get_driver, logger
|
||||
|
||||
from .config import Config
|
||||
from .text_parser import TextParser
|
||||
from .image_render import ImageRenderer
|
||||
from .sender import sender
|
||||
|
||||
|
||||
# 请求体模型
|
||||
class PushRequest(BaseModel):
|
||||
"""推送请求模型"""
|
||||
group_id: int
|
||||
"""接收消息的 QQ 群号"""
|
||||
|
||||
qq: int
|
||||
"""被 @ 的 QQ 号"""
|
||||
|
||||
text: str
|
||||
"""通知文本(# 表示换行)"""
|
||||
|
||||
|
||||
# 响应模型
|
||||
class PushResponse(BaseModel):
|
||||
"""推送响应模型"""
|
||||
success: bool
|
||||
"""是否成功"""
|
||||
|
||||
message: str
|
||||
"""响应消息"""
|
||||
|
||||
data: Optional[dict] = None
|
||||
"""返回数据(如有)"""
|
||||
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def create_routes(token: str, config: Config):
|
||||
"""
|
||||
创建 API 路由
|
||||
|
||||
Args:
|
||||
token: 鉴权 Token
|
||||
config: 配置对象
|
||||
"""
|
||||
|
||||
@router.post(f"/danding/qqpush/{token}", response_model=PushResponse)
|
||||
async def qqpush(request: Request, data: PushRequest):
|
||||
"""
|
||||
QQ 消息推送接口
|
||||
|
||||
Args:
|
||||
request: FastAPI 请求对象
|
||||
data: 推送请求数据
|
||||
|
||||
Returns:
|
||||
推送结果
|
||||
"""
|
||||
try:
|
||||
# 1. 验证参数
|
||||
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):
|
||||
raise HTTPException(status_code=400, detail="text 不能为空且必须是字符串")
|
||||
|
||||
# 2. 检查 Bot 是否在线
|
||||
bot = sender.get_bot()
|
||||
if not bot:
|
||||
logger.error("Bot 实例未设置,无法发送消息")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Bot 未连接,请检查机器人状态"
|
||||
)
|
||||
|
||||
# 3. 文本处理
|
||||
text_parser = TextParser(max_length=config.MaxTextLength)
|
||||
if not text_parser.validate_text(data.text):
|
||||
raise HTTPException(status_code=400, detail="文本内容无效")
|
||||
|
||||
parsed_text = text_parser.parse(data.text)
|
||||
logger.info(f"解析文本: {parsed_text[:50]}..." if len(parsed_text) > 50 else parsed_text)
|
||||
|
||||
# 4. 生成图片
|
||||
image_renderer = ImageRenderer(
|
||||
width=config.ImageWidth,
|
||||
font_size=config.ImageFontSize,
|
||||
padding=config.ImagePadding,
|
||||
line_spacing=config.ImageLineSpacing,
|
||||
bg_color=config.ImageBgColor,
|
||||
text_color=config.ImageTextColor,
|
||||
font_paths=config.FontPaths
|
||||
)
|
||||
|
||||
image_base64 = image_renderer.render_to_base64(parsed_text)
|
||||
logger.info("图片生成成功")
|
||||
|
||||
# 5. 发送消息
|
||||
send_result = await sender.send_to_group(
|
||||
group_id=data.group_id,
|
||||
qq=data.qq,
|
||||
image_base64=image_base64
|
||||
)
|
||||
|
||||
if not send_result["success"]:
|
||||
logger.error(f"消息发送失败: {send_result['error']}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=send_result["message"]
|
||||
)
|
||||
|
||||
logger.info(f"消息发送成功 - 群: {data.group_id}, @: {data.qq}")
|
||||
|
||||
return PushResponse(
|
||||
success=True,
|
||||
message="推送成功",
|
||||
data={
|
||||
"group_id": data.group_id,
|
||||
"qq": data.qq,
|
||||
"message_id": send_result["data"].get("message_id")
|
||||
}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"推送接口异常: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"服务器内部错误: {str(e)}"
|
||||
)
|
||||
|
||||
return router
|
||||
42
danding_bot/plugins/danding_qqpush/config.py
Normal file
42
danding_bot/plugins/danding_qqpush/config.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Danding_QqPush 插件配置模块"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
"""插件配置"""
|
||||
|
||||
Token: str = "danding-8HkL9xQ2"
|
||||
"""API 访问 Token,用于鉴权"""
|
||||
|
||||
# 图片生成配置
|
||||
ImageWidth: int = 800
|
||||
"""生成的图片宽度(像素)"""
|
||||
|
||||
ImageFontSize: int = 24
|
||||
"""字体大小(像素)"""
|
||||
|
||||
ImagePadding: int = 30
|
||||
"""图片内边距(像素)"""
|
||||
|
||||
ImageLineSpacing: float = 1.4
|
||||
"""行距倍数"""
|
||||
|
||||
ImageBgColor: tuple = (252, 252, 252)
|
||||
"""图片背景颜色 (R, G, B)"""
|
||||
|
||||
ImageTextColor: tuple = (0, 0, 0)
|
||||
"""文本颜色 (R, G, B)"""
|
||||
|
||||
# 文本处理配置
|
||||
MaxTextLength: int = 2000
|
||||
"""最大文本长度(字符数),超过将截断"""
|
||||
|
||||
# 字体路径配置
|
||||
FontPaths: list = [
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/wqy-microhei/wqy-microhei.ttc",
|
||||
"/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc",
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
"C:/Windows/Fonts/simhei.ttf",
|
||||
]
|
||||
"""字体文件路径列表"""
|
||||
207
danding_bot/plugins/danding_qqpush/image_render.py
Normal file
207
danding_bot/plugins/danding_qqpush/image_render.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""图片生成模块 - 将文本渲染为图片"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
import base64
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
class ImageRenderer:
|
||||
"""图片渲染器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int = 800,
|
||||
font_size: int = 24,
|
||||
padding: int = 30,
|
||||
line_spacing: float = 1.4,
|
||||
bg_color: Tuple[int, int, int] = (252, 252, 252),
|
||||
text_color: Tuple[int, int, int] = (0, 0, 0),
|
||||
font_paths: list = None
|
||||
):
|
||||
"""
|
||||
初始化图片渲染器
|
||||
|
||||
Args:
|
||||
width: 图片宽度
|
||||
font_size: 字体大小
|
||||
padding: 内边距
|
||||
line_spacing: 行距倍数
|
||||
bg_color: 背景颜色 (R, G, B)
|
||||
text_color: 文本颜色 (R, G, B)
|
||||
font_paths: 字体文件路径列表
|
||||
"""
|
||||
self.width = width
|
||||
self.font_size = font_size
|
||||
self.padding = padding
|
||||
self.line_spacing = line_spacing
|
||||
self.bg_color = bg_color
|
||||
self.text_color = text_color
|
||||
self.font_paths = font_paths or []
|
||||
|
||||
# 加载字体
|
||||
self.font = self._load_font()
|
||||
|
||||
def _load_font(self) -> ImageFont.FreeTypeFont:
|
||||
"""加载字体"""
|
||||
return self._load_font_by_size(self.font_size)
|
||||
|
||||
def _load_font_by_size(self, font_size: int) -> ImageFont.FreeTypeFont:
|
||||
"""
|
||||
加载指定大小的字体
|
||||
|
||||
Args:
|
||||
font_size: 字体大小
|
||||
|
||||
Returns:
|
||||
字体对象
|
||||
"""
|
||||
for font_path in self.font_paths:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
return font
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 如果都失败了,使用默认字体
|
||||
return ImageFont.load_default()
|
||||
|
||||
def _calculate_text_dimensions(self, text: str) -> Tuple[int, int]:
|
||||
"""
|
||||
计算文本的尺寸
|
||||
|
||||
Args:
|
||||
text: 文本内容
|
||||
|
||||
Returns:
|
||||
(宽度, 高度)
|
||||
"""
|
||||
# 创建一个临时图片用于计算
|
||||
test_img = Image.new('RGB', (1, 1), color=self.bg_color)
|
||||
test_draw = ImageDraw.Draw(test_img)
|
||||
|
||||
bbox = test_draw.textbbox((0, 0), text, font=self.font)
|
||||
width = bbox[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
|
||||
return width, height
|
||||
|
||||
def _smart_text_wrap(self, text: str, max_width: int) -> list:
|
||||
"""
|
||||
智能文本换行
|
||||
|
||||
Args:
|
||||
text: 文本内容
|
||||
max_width: 最大宽度
|
||||
|
||||
Returns:
|
||||
换行后的文本列表
|
||||
"""
|
||||
lines = []
|
||||
current_line = ""
|
||||
current_width = 0
|
||||
|
||||
for char in text:
|
||||
char_width, _ = self._calculate_text_dimensions(char)
|
||||
|
||||
if current_width + char_width <= max_width:
|
||||
current_line += char
|
||||
current_width += char_width
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = char
|
||||
current_width = char_width
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
return lines
|
||||
|
||||
def render(self, text: str) -> bytes:
|
||||
"""
|
||||
将文本渲染为图片
|
||||
|
||||
Args:
|
||||
text: 文本内容(已处理换行符)
|
||||
|
||||
Returns:
|
||||
图片的字节数据
|
||||
"""
|
||||
if not text:
|
||||
text = "(空消息)"
|
||||
|
||||
# 计算有效宽度
|
||||
effective_width = self.width - (2 * self.padding)
|
||||
|
||||
# 按段落处理
|
||||
all_lines = []
|
||||
for paragraph in text.split('\n'):
|
||||
if not paragraph:
|
||||
all_lines.append("")
|
||||
continue
|
||||
|
||||
wrapped_lines = self._smart_text_wrap(paragraph, effective_width)
|
||||
all_lines.extend(wrapped_lines)
|
||||
|
||||
# 计算行高
|
||||
_, line_height = self._calculate_text_dimensions("测试")
|
||||
total_line_height = int(line_height * self.line_spacing)
|
||||
|
||||
# 标题相关
|
||||
title = "蛋定助手通知您:"
|
||||
title_font_size = int(self.font_size * 1.3) # 标题字体放大1.3倍
|
||||
title_font = self._load_font_by_size(title_font_size)
|
||||
title_height = int(line_height * 1.5) # 标题区域高度
|
||||
divider_height = 2 # 分割线高度
|
||||
divider_spacing = 10 # 分割线与标题的间距
|
||||
|
||||
# 计算总高度(包含标题区域)
|
||||
content_height = (len(all_lines) * total_line_height) + (2 * self.padding)
|
||||
header_height = title_height + divider_spacing + divider_height + self.padding
|
||||
total_height = content_height + header_height
|
||||
height = max(total_height, 200) # 最小高度
|
||||
|
||||
# 创建图片
|
||||
image = Image.new('RGB', (self.width, height), color=self.bg_color)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# 绘制标题(加粗效果通过增大字体实现)
|
||||
title_x = self.padding
|
||||
title_y = self.padding
|
||||
draw.text((title_x, title_y), title, font=title_font, fill=self.text_color)
|
||||
|
||||
# 绘制分割线
|
||||
divider_y = title_y + title_height + 10
|
||||
draw.line(
|
||||
[(self.padding, divider_y), (self.width - self.padding, divider_y)],
|
||||
fill=(200, 200, 200),
|
||||
width=divider_height
|
||||
)
|
||||
|
||||
# 绘制文本内容
|
||||
y = divider_y + divider_spacing + self.padding
|
||||
for line in all_lines:
|
||||
if line: # 跳过空行
|
||||
draw.text((self.padding, y), line, font=self.font, fill=self.text_color)
|
||||
y += total_line_height
|
||||
|
||||
# 转换为字节流
|
||||
img_byte_arr = io.BytesIO()
|
||||
image.save(img_byte_arr, format='PNG', quality=95)
|
||||
img_byte_arr.seek(0)
|
||||
|
||||
return img_byte_arr.getvalue()
|
||||
|
||||
def render_to_base64(self, text: str) -> str:
|
||||
"""
|
||||
将文本渲染为图片并返回 base64 编码
|
||||
|
||||
Args:
|
||||
text: 文本内容
|
||||
|
||||
Returns:
|
||||
base64 编码的图片数据
|
||||
"""
|
||||
img_bytes = self.render(text)
|
||||
base64_str = base64.b64encode(img_bytes).decode('utf-8')
|
||||
return f"base64://{base64_str}"
|
||||
146
danding_bot/plugins/danding_qqpush/sender.py
Normal file
146
danding_bot/plugins/danding_qqpush/sender.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""消息发送模块 - 负责向 QQ 群发送消息"""
|
||||
from typing import Optional
|
||||
from nonebot import get_bots
|
||||
from nonebot.adapters.onebot.v11 import Bot, Message, MessageSegment
|
||||
|
||||
|
||||
class MessageSender:
|
||||
"""消息发送器"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化消息发送器"""
|
||||
self.bot: Optional[Bot] = None
|
||||
|
||||
def set_bot(self, bot: Bot):
|
||||
"""
|
||||
设置 Bot 实例
|
||||
|
||||
Args:
|
||||
bot: OneBot V11 Bot 实例
|
||||
"""
|
||||
self.bot = bot
|
||||
|
||||
def get_bot(self) -> Optional[Bot]:
|
||||
"""
|
||||
获取 Bot 实例
|
||||
|
||||
Returns:
|
||||
Bot 实例,如果未设置则尝试从全局获取
|
||||
"""
|
||||
if self.bot:
|
||||
return self.bot
|
||||
|
||||
# 尝试从全局获取 Bot
|
||||
try:
|
||||
bots = get_bots()
|
||||
if bots:
|
||||
bot = list(bots.values())[0]
|
||||
self.bot = bot
|
||||
return bot
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
async def send_to_group(
|
||||
self,
|
||||
group_id: int,
|
||||
qq: int,
|
||||
image_base64: str
|
||||
) -> dict:
|
||||
"""
|
||||
向指定群发送消息(@用户 + 图片)
|
||||
|
||||
Args:
|
||||
group_id: 群号
|
||||
qq: 要 @ 的 QQ 号
|
||||
image_base64: 图片的 base64 编码(格式:base64://...)
|
||||
|
||||
Returns:
|
||||
发送结果字典
|
||||
|
||||
Raises:
|
||||
ValueError: Bot 未设置
|
||||
Exception: 发送失败
|
||||
"""
|
||||
bot = self.get_bot()
|
||||
if not bot:
|
||||
raise ValueError("Bot 实例未设置,无法发送消息")
|
||||
|
||||
try:
|
||||
# 构造消息:@用户 + 图片
|
||||
message = Message()
|
||||
message.append(MessageSegment.at(qq))
|
||||
message.append(MessageSegment.image(image_base64))
|
||||
|
||||
# 发送群消息
|
||||
result = await bot.call_api(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=message
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result,
|
||||
"message": "消息发送成功"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 捕获异常并返回错误信息
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"消息发送失败: {str(e)}"
|
||||
}
|
||||
|
||||
async def send_text_to_group(
|
||||
self,
|
||||
group_id: int,
|
||||
qq: int,
|
||||
text: str
|
||||
) -> dict:
|
||||
"""
|
||||
向指定群发送纯文本消息(@用户 + 文本)
|
||||
|
||||
Args:
|
||||
group_id: 群号
|
||||
qq: 要 @ 的 QQ 号
|
||||
text: 文本内容
|
||||
|
||||
Returns:
|
||||
发送结果字典
|
||||
"""
|
||||
bot = self.get_bot()
|
||||
if not bot:
|
||||
raise ValueError("Bot 实例未设置,无法发送消息")
|
||||
|
||||
try:
|
||||
# 构造消息:@用户 + 文本
|
||||
message = Message()
|
||||
message.append(MessageSegment.at(qq))
|
||||
message.append(MessageSegment.text(text))
|
||||
|
||||
# 发送群消息
|
||||
result = await bot.call_api(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=message
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result,
|
||||
"message": "消息发送成功"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"消息发送失败: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
# 全局消息发送器实例
|
||||
sender = MessageSender()
|
||||
69
danding_bot/plugins/danding_qqpush/text_parser.py
Normal file
69
danding_bot/plugins/danding_qqpush/text_parser.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""文本处理模块 - 处理文本换行和格式化"""
|
||||
from typing import List
|
||||
|
||||
|
||||
class TextParser:
|
||||
"""文本解析器"""
|
||||
|
||||
def __init__(self, max_length: int = 2000):
|
||||
"""
|
||||
初始化文本解析器
|
||||
|
||||
Args:
|
||||
max_length: 最大文本长度,超过将被截断
|
||||
"""
|
||||
self.max_length = max_length
|
||||
|
||||
def parse(self, text: str) -> str:
|
||||
"""
|
||||
解析文本,将 # 转换为换行符
|
||||
|
||||
Args:
|
||||
text: 原始文本,使用 # 表示换行
|
||||
|
||||
Returns:
|
||||
处理后的文本
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 截断超长文本
|
||||
if len(text) > self.max_length:
|
||||
text = text[:self.max_length]
|
||||
text += "...(文本过长已截断)"
|
||||
|
||||
# 将 # 替换为换行符
|
||||
parsed_text = text.replace("#", "\n")
|
||||
|
||||
return parsed_text
|
||||
|
||||
def parse_to_lines(self, text: str) -> List[str]:
|
||||
"""
|
||||
解析文本并返回行列表
|
||||
|
||||
Args:
|
||||
text: 原始文本
|
||||
|
||||
Returns:
|
||||
文本行列表
|
||||
"""
|
||||
parsed_text = self.parse(text)
|
||||
return parsed_text.split("\n")
|
||||
|
||||
def validate_text(self, text: str) -> bool:
|
||||
"""
|
||||
验证文本是否有效
|
||||
|
||||
Args:
|
||||
text: 待验证的文本
|
||||
|
||||
Returns:
|
||||
是否有效
|
||||
"""
|
||||
if not text or not isinstance(text, str):
|
||||
return False
|
||||
|
||||
if len(text.strip()) == 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
52
danding_bot/plugins/danding_qqpush/utils.py
Normal file
52
danding_bot/plugins/danding_qqpush/utils.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""工具函数模块"""
|
||||
import secrets
|
||||
import string
|
||||
|
||||
|
||||
def generate_token(length: int = 16, prefix: str = "danding-") -> str:
|
||||
"""
|
||||
生成随机 Token
|
||||
|
||||
Args:
|
||||
length: 随机部分长度
|
||||
prefix: Token 前缀
|
||||
|
||||
Returns:
|
||||
生成的 Token
|
||||
"""
|
||||
# 生成随机字符串(字母和数字)
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
return f"{prefix}{random_part}"
|
||||
|
||||
|
||||
def validate_token(token: str, expected_token: str) -> bool:
|
||||
"""
|
||||
验证 Token 是否正确
|
||||
|
||||
Args:
|
||||
token: 待验证的 Token
|
||||
expected_token: 期望的 Token
|
||||
|
||||
Returns:
|
||||
是否匹配
|
||||
"""
|
||||
if not token or not expected_token:
|
||||
return False
|
||||
|
||||
return token == expected_token
|
||||
|
||||
|
||||
def format_log_message(message: str, level: str = "INFO") -> str:
|
||||
"""
|
||||
格式化日志消息
|
||||
|
||||
Args:
|
||||
message: 原始消息
|
||||
level: 日志级别
|
||||
|
||||
Returns:
|
||||
格式化后的消息
|
||||
"""
|
||||
return f"[Danding_QqPush] [{level}] {message}"
|
||||
Binary file not shown.
@@ -383,25 +383,16 @@ async def handle_triple_gacha(bot: Bot, event: MessageEvent, state: T_State):
|
||||
# 如果抽到SSR/SP,处理奖励发放
|
||||
if ssr_count > 0:
|
||||
# 为每张SSR/SP处理奖励
|
||||
auto_rewards = 0
|
||||
manual_rewards = 0
|
||||
|
||||
# 这里简化处理,只处理一次奖励(因为每次抽卡都是独立的)
|
||||
# 如果需要为每张SSR/SP都发放奖励,可以循环处理
|
||||
auto_success, reward_msg = await process_ssr_sp_reward(user_id)
|
||||
if auto_success:
|
||||
auto_rewards += 1
|
||||
else:
|
||||
manual_rewards += 1
|
||||
auto_success, reward_msg = await process_ssr_sp_reward(user_id, ssr_count)
|
||||
|
||||
msg.append(f"\n\n{reward_msg}")
|
||||
|
||||
# 通知管理员
|
||||
admin_msg = f"🎉 用户 {user_name}({user_id}) 在三连抽中抽到了 {ssr_count} 张 SSR/SP!"
|
||||
if auto_rewards > 0:
|
||||
admin_msg += f" 已自动发放 {auto_rewards} 张奖励!"
|
||||
if manual_rewards > 0:
|
||||
admin_msg += f" 需要手动发放 {manual_rewards} 张奖励!"
|
||||
if auto_success:
|
||||
admin_msg += f" 已自动发放 {ssr_count} 张奖励!"
|
||||
else:
|
||||
admin_msg += f" 需要手动发放 {ssr_count} 张奖励!"
|
||||
await notify_admin(bot, admin_msg)
|
||||
|
||||
await triple_gacha_matcher.finish(msg)
|
||||
|
||||
@@ -74,55 +74,70 @@ async def query_qq_binding(qq: str) -> Tuple[bool, Optional[str], Optional[str]]
|
||||
logger.error(f"查询QQ绑定状态异常: {str(e)}")
|
||||
return False, None, None
|
||||
|
||||
async def add_user_viptime(username: str, time_class: str = "Day") -> Tuple[bool, str]:
|
||||
async def add_user_viptime(username: str, time_class: str = "Day", count: int = 1) -> Tuple[bool, str]:
|
||||
"""
|
||||
为用户添加VIP时间
|
||||
|
||||
Args:
|
||||
username: 蛋定用户名
|
||||
time_class: 时间类型 (Hour/Day/Week/Month/Season/Year)
|
||||
count: 添加次数(默认为1)
|
||||
|
||||
Returns:
|
||||
Tuple[是否成功, 响应消息]
|
||||
"""
|
||||
try:
|
||||
url = f"{DD_API_HOST}bot_add_user_viptime"
|
||||
data = {
|
||||
"user": BOT_USER_ID,
|
||||
"token": BOT_TOKEN,
|
||||
"username": username,
|
||||
"classes": time_class
|
||||
}
|
||||
|
||||
response = requests.post(url=url, json=data)
|
||||
logger.debug(f"添加VIP时间响应: {response}")
|
||||
# 如果count大于1,需要多次调用API
|
||||
success_count = 0
|
||||
last_message = ""
|
||||
|
||||
if response.status_code != 200:
|
||||
error_msg = f"添加VIP时间失败,状态码: {response.status_code}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
for i in range(count):
|
||||
data = {
|
||||
"user": BOT_USER_ID,
|
||||
"token": BOT_TOKEN,
|
||||
"username": username,
|
||||
"classes": time_class
|
||||
}
|
||||
|
||||
result = response.json()
|
||||
logger.debug(f"添加VIP时间结果: {result}")
|
||||
response = requests.post(url=url, json=data)
|
||||
logger.debug(f"添加VIP时间响应({i+1}/{count}): {response}")
|
||||
|
||||
if result.get("code") == 200:
|
||||
return True, result.get("msg", "添加VIP时间成功")
|
||||
if response.status_code != 200:
|
||||
error_msg = f"添加VIP时间失败({i+1}/{count}),状态码: {response.status_code}"
|
||||
logger.error(error_msg)
|
||||
continue
|
||||
|
||||
result = response.json()
|
||||
logger.debug(f"添加VIP时间结果({i+1}/{count}): {result}")
|
||||
|
||||
if result.get("code") == 200:
|
||||
success_count += 1
|
||||
last_message = result.get("msg", "添加VIP时间成功")
|
||||
else:
|
||||
error_msg = result.get("msg", "添加VIP时间失败")
|
||||
logger.error(f"添加VIP时间失败({i+1}/{count}): {error_msg}")
|
||||
|
||||
if success_count == count:
|
||||
return True, f"成功添加{count}次{time_class}时长。{last_message}"
|
||||
elif success_count > 0:
|
||||
return False, f"仅成功添加{success_count}/{count}次{time_class}时长。{last_message}"
|
||||
else:
|
||||
error_msg = result.get("msg", "添加VIP时间失败")
|
||||
logger.error(f"添加VIP时间失败: {error_msg}")
|
||||
return False, error_msg
|
||||
return False, f"添加{count}次{time_class}时长全部失败。"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"添加VIP时间异常: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
async def process_ssr_sp_reward(user_id: str) -> Tuple[bool, str]:
|
||||
async def process_ssr_sp_reward(user_id: str, count: int = 1) -> Tuple[bool, str]:
|
||||
"""
|
||||
处理SSR/SP奖励发放
|
||||
|
||||
Args:
|
||||
user_id: QQ用户ID
|
||||
count: 奖励数量(默认为1)
|
||||
|
||||
Returns:
|
||||
Tuple[是否自动发放成功, 消息内容]
|
||||
@@ -132,26 +147,42 @@ async def process_ssr_sp_reward(user_id: str) -> Tuple[bool, str]:
|
||||
|
||||
if not is_bound:
|
||||
# 用户未绑定,返回提示信息
|
||||
msg = (f"🎉恭喜您抽中了SSR/SP稀有度式神!🎉\n"
|
||||
f"获得奖励:蛋定助手天卡一张\n"
|
||||
f"获取奖励请联系管理员,或前往蛋定云服务中绑定QQ号即可体验自动加时!")
|
||||
if count == 1:
|
||||
msg = (f"🎉恭喜您抽中了SSR/SP稀有度式神!🎉\n"
|
||||
f"获得奖励:蛋定助手天卡一张\n"
|
||||
f"获取奖励请联系管理员,或前往蛋定云服务中绑定QQ号即可体验自动加时!")
|
||||
else:
|
||||
msg = (f"🎉恭喜您抽中了{count}张SSR/SP稀有度式神!🎉\n"
|
||||
f"获得奖励:蛋定助手天卡{count}张\n"
|
||||
f"获取奖励请联系管理员,或前往蛋定云服务中绑定QQ号即可体验自动加时!")
|
||||
return False, msg
|
||||
else:
|
||||
# 用户已绑定,自动加时
|
||||
success, message = await add_user_viptime(username, "Day")
|
||||
success, message = await add_user_viptime(username, "Day", count)
|
||||
|
||||
if success:
|
||||
masked_username = mask_username(username)
|
||||
msg = (f"🎉恭喜您抽中了SSR/SP稀有度式神!🎉\n"
|
||||
f"🎁已自动为您的蛋定账号({masked_username})添加天卡时长!\n"
|
||||
f"📅到期时间: {message.split('到期时间为:')[-1] if '到期时间为:' in message else '请查看您的账号详情'}")
|
||||
if count == 1:
|
||||
msg = (f"🎉恭喜您抽中了SSR/SP稀有度式神!🎉\n"
|
||||
f"🎁已自动为您的蛋定账号({masked_username})添加天卡时长!\n"
|
||||
f"📅到期时间: {message.split('到期时间为:')[-1] if '到期时间为:' in message else '请查看您的账号详情'}")
|
||||
else:
|
||||
msg = (f"🎉恭喜您抽中了{count}张SSR/SP稀有度式神!🎉\n"
|
||||
f"🎁已自动为您的蛋定账号({masked_username})添加{count}天卡时长!\n"
|
||||
f"📅到期时间: {message.split('到期时间为:')[-1] if '到期时间为:' in message else '请查看您的账号详情'}")
|
||||
return True, msg
|
||||
else:
|
||||
# 自动加时失败,返回错误信息和手动领取提示
|
||||
msg = (f"🎉恭喜您抽中了SSR/SP稀有度式神!🎉\n"
|
||||
f"获得奖励:蛋定助手天卡一张\n"
|
||||
f"⚠️自动加时失败: {message}\n"
|
||||
f"请联系管理员手动领取奖励!")
|
||||
if count == 1:
|
||||
msg = (f"🎉恭喜您抽中了SSR/SP稀有度式神!🎉\n"
|
||||
f"获得奖励:蛋定助手天卡一张\n"
|
||||
f"⚠️自动加时失败: {message}\n"
|
||||
f"请联系管理员手动领取奖励!")
|
||||
else:
|
||||
msg = (f"🎉恭喜您抽中了{count}张SSR/SP稀有度式神!🎉\n"
|
||||
f"获得奖励:蛋定助手天卡{count}张\n"
|
||||
f"⚠️自动加时失败: {message}\n"
|
||||
f"请联系管理员手动领取奖励!")
|
||||
return False, msg
|
||||
|
||||
async def process_achievement_reward(user_id: str, achievement_id: str) -> Tuple[bool, str]:
|
||||
|
||||
Binary file not shown.
49
test_qqpush.py
Normal file
49
test_qqpush.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Danding_QqPush 插件测试脚本"""
|
||||
import requests
|
||||
import json
|
||||
|
||||
|
||||
def test_qqpush_api():
|
||||
"""测试 QQ 推送 API"""
|
||||
|
||||
# 配置
|
||||
base_url = "http://localhost:8080" # NoneBot 默认端口
|
||||
token = "danding-8HkL9xQ2" # 默认 Token
|
||||
|
||||
# 构造请求数据
|
||||
data = {
|
||||
"group_id": 574727392, # 替换为实际群号
|
||||
"qq": 1424473282, # 替换为实际 QQ 号
|
||||
"text": "系统告警#数据库连接失败#请立即处理#测试消息"
|
||||
}
|
||||
|
||||
# 发送请求
|
||||
url = f"{base_url}/danding/qqpush/{token}"
|
||||
|
||||
print(f"正在测试 API: {url}")
|
||||
print(f"请求数据: {json.dumps(data, ensure_ascii=False, indent=2)}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
print(f"\n响应状态码: {response.status_code}")
|
||||
print(f"响应内容: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
|
||||
if response.status_code == 200:
|
||||
print("\n✅ 测试成功!")
|
||||
else:
|
||||
print(f"\n❌ 测试失败,状态码: {response.status_code}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("\n❌ 连接失败,请确认 NoneBot 是否已启动")
|
||||
except Exception as e:
|
||||
print(f"\n❌ 测试异常: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_qqpush_api()
|
||||
Reference in New Issue
Block a user