feat(qqpush): 私聊推送接口与抽卡API token读取兼容
This commit is contained in:
@@ -30,10 +30,21 @@ def _track_task(task: asyncio.Task) -> None:
|
|||||||
async def handle_api_result(
|
async def handle_api_result(
|
||||||
bot: Bot, exception: Optional[Exception], api: str, data: Dict[str, Any], result: Any
|
bot: Bot, exception: Optional[Exception], api: str, data: Dict[str, Any], result: Any
|
||||||
):
|
):
|
||||||
"""拦截发送消息API调用,监控发出的消息"""
|
"""拦截发送消息API调用,仅对群消息进行撤回,私人消息不撤回"""
|
||||||
if api not in ("send_msg", "send_group_msg", "send_private_msg") or exception:
|
if exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 私人消息不撤回:send_private_msg 直接跳过
|
||||||
|
if api == "send_private_msg":
|
||||||
|
return
|
||||||
|
|
||||||
|
# send_msg 是通用接口,根据 message_type 区分
|
||||||
|
if api == "send_msg":
|
||||||
|
if data.get("message_type") == "private":
|
||||||
|
return # 通过 send_msg 发送的私人消息也不撤回
|
||||||
|
elif api != "send_group_msg":
|
||||||
|
return # 非消息发送 API,跳过
|
||||||
|
|
||||||
message_id = result.get("message_id")
|
message_id = result.get("message_id")
|
||||||
if not message_id:
|
if not message_id:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ async def handle_message(event: MessageEvent, bot: Bot):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 保存task引用防止GC回收
|
# 仅群消息撤回,私人消息不撤回
|
||||||
|
if event.message_type != "private":
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
_delete_message_after_delay(bot, sent_message["message_id"])
|
_delete_message_after_delay(bot, sent_message["message_id"])
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,6 +53,28 @@ class PushRequest(BaseModel):
|
|||||||
"""公告发布时间"""
|
"""公告发布时间"""
|
||||||
|
|
||||||
|
|
||||||
|
class PrivatePushRequest(BaseModel):
|
||||||
|
"""私聊推送请求模型"""
|
||||||
|
user_id: int
|
||||||
|
"""接收私聊消息的好友 QQ 号"""
|
||||||
|
|
||||||
|
text: Optional[str] = None
|
||||||
|
"""通知文本(# 表示换行)"""
|
||||||
|
|
||||||
|
text_only: bool = False
|
||||||
|
"""True=发送纯文本;False(默认)=渲染为图片发送"""
|
||||||
|
|
||||||
|
html: Optional[str] = None
|
||||||
|
"""可选的完整公告 HTML;传入时使用虚拟浏览器截图"""
|
||||||
|
|
||||||
|
title: Optional[str] = None
|
||||||
|
"""公告标题"""
|
||||||
|
|
||||||
|
published_at: Optional[str] = None
|
||||||
|
"""公告发布时间"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 响应模型
|
# 响应模型
|
||||||
class PushResponse(BaseModel):
|
class PushResponse(BaseModel):
|
||||||
"""推送响应模型"""
|
"""推送响应模型"""
|
||||||
@@ -169,4 +191,91 @@ def create_routes(token: str, config: Config):
|
|||||||
detail="服务器内部错误"
|
detail="服务器内部错误"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/danding/private/{request_token}", response_model=PushResponse)
|
||||||
|
async def private_push(request_token: str, request: Request, data: PrivatePushRequest):
|
||||||
|
"""
|
||||||
|
好友私聊推送接口
|
||||||
|
|
||||||
|
仅可向好友发送私聊消息;目标不在好友列表或发送失败时返回错误。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not validate_token(request_token, token):
|
||||||
|
raise HTTPException(status_code=403, detail="Token 验证失败")
|
||||||
|
|
||||||
|
# 1. 验证参数
|
||||||
|
if not data.user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="user_id 不能为空")
|
||||||
|
|
||||||
|
if not (data.text or data.html):
|
||||||
|
raise HTTPException(status_code=400, detail="text 和 html 不能同时为空")
|
||||||
|
|
||||||
|
# 2. 检查 Bot 是否在线
|
||||||
|
bot = sender.get_bot()
|
||||||
|
if not bot:
|
||||||
|
logger.error("Bot 实例未设置,无法发送消息")
|
||||||
|
raise HTTPException(status_code=500, detail="Bot 未连接,请检查机器人状态")
|
||||||
|
|
||||||
|
# 3. 检查目标是否为好友
|
||||||
|
try:
|
||||||
|
is_friend = await sender.is_friend(data.user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询好友列表失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"查询好友列表失败: {e}")
|
||||||
|
|
||||||
|
if not is_friend:
|
||||||
|
logger.warning(f"目标用户不是好友: {data.user_id}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"用户 {data.user_id} 不在好友列表中,无法发送私聊消息"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 发送消息
|
||||||
|
if data.text_only and data.text:
|
||||||
|
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)
|
||||||
|
send_result = await sender.send_text_to_private(data.user_id, parsed_text)
|
||||||
|
elif data.html:
|
||||||
|
image_base64 = await render_article_to_base64(
|
||||||
|
data.html,
|
||||||
|
data.title or "蛋定助手通知",
|
||||||
|
data.published_at,
|
||||||
|
)
|
||||||
|
send_result = await sender.send_to_private(data.user_id, image_base64)
|
||||||
|
else:
|
||||||
|
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)
|
||||||
|
image_base64 = await asyncio.to_thread(_get_renderer(config).render_to_base64, parsed_text)
|
||||||
|
send_result = await sender.send_to_private(data.user_id, image_base64)
|
||||||
|
|
||||||
|
logger.info("私聊文本已准备发送" if data.text_only else "私聊图片生成成功")
|
||||||
|
|
||||||
|
if not send_result["success"]:
|
||||||
|
logger.error(f"私聊消息发送失败: {send_result.get('error')}")
|
||||||
|
raise HTTPException(status_code=500, detail=send_result["message"])
|
||||||
|
|
||||||
|
logger.info(f"私聊消息发送成功 - 好友: {data.user_id}")
|
||||||
|
|
||||||
|
return PushResponse(
|
||||||
|
success=True,
|
||||||
|
message="私聊推送成功",
|
||||||
|
data={
|
||||||
|
"user_id": data.user_id,
|
||||||
|
"message_id": send_result["data"].get("message_id")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"私聊推送接口异常: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="服务器内部错误"
|
||||||
|
)
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -86,5 +86,49 @@ class MessageSender:
|
|||||||
return await self._send_msg(group_id, MessageSegment.text(text), qq)
|
return await self._send_msg(group_id, MessageSegment.text(text), qq)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_private(self, user_id: int, segment) -> dict:
|
||||||
|
"""
|
||||||
|
内部通用私聊发送方法(任意消息段)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 目标好友 QQ 号
|
||||||
|
segment: MessageSegment 实例
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
发送结果字典
|
||||||
|
"""
|
||||||
|
bot = self.get_bot()
|
||||||
|
if not bot:
|
||||||
|
raise ValueError("Bot 实例未设置,无法发送消息")
|
||||||
|
try:
|
||||||
|
message = Message([segment])
|
||||||
|
result = await bot.call_api(
|
||||||
|
"send_private_msg",
|
||||||
|
user_id=user_id,
|
||||||
|
message=message,
|
||||||
|
__qqpush_source="danding_qqpush",
|
||||||
|
)
|
||||||
|
return {"success": True, "data": result, "message": "私聊消息发送成功"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[QqPush] 私聊发送失败 user={user_id}: {e}")
|
||||||
|
return {"success": False, "error": str(e), "message": f"私聊消息发送失败: {e}"}
|
||||||
|
|
||||||
|
async def send_to_private(self, user_id: int, image_base64: str) -> dict:
|
||||||
|
"""向指定好友发送图片。"""
|
||||||
|
return await self._send_private(user_id, MessageSegment.image(image_base64))
|
||||||
|
|
||||||
|
async def send_text_to_private(self, user_id: int, text: str) -> dict:
|
||||||
|
"""向指定好友发送纯文本消息。"""
|
||||||
|
return await self._send_private(user_id, MessageSegment.text(text))
|
||||||
|
|
||||||
|
async def is_friend(self, user_id: int) -> bool:
|
||||||
|
"""检查指定用户是否为好友(查询好友列表)。"""
|
||||||
|
bot = self.get_bot()
|
||||||
|
if not bot:
|
||||||
|
raise ValueError("Bot 实例未设置,无法查询好友列表")
|
||||||
|
friends = await bot.call_api("get_friend_list")
|
||||||
|
return any(int(f.get("user_id", 0)) == int(user_id) for f in friends)
|
||||||
|
|
||||||
|
|
||||||
# 全局消息发送器实例
|
# 全局消息发送器实例
|
||||||
sender = MessageSender()
|
sender = MessageSender()
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ class MessageService:
|
|||||||
self.last_messages[scope] = {}
|
self.last_messages[scope] = {}
|
||||||
self.last_messages[scope][message_type] = message_id
|
self.last_messages[scope][message_type] = message_id
|
||||||
|
|
||||||
# Schedule auto-recall if configured
|
# Schedule auto-recall if configured (仅群消息撤回,私聊不撤回)
|
||||||
recall_delay = self.config.MESSAGE_RECALL.get(message_type, 0)
|
recall_delay = self.config.MESSAGE_RECALL.get(message_type, 0)
|
||||||
if recall_delay > 0:
|
if recall_delay > 0 and is_group:
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
self._schedule_recall(bot, scope, message_id, recall_delay)
|
self._schedule_recall(bot, scope, message_id, recall_delay)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
from pydantic import field_validator, model_validator
|
from pydantic import Field, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger("onmyoji_gacha")
|
logger = logging.getLogger("onmyoji_gacha")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_nonebot_config(name: str) -> str:
|
||||||
|
"""从 NoneBot driver.config 读取配置;测试或独立加载模块时静默回退。"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from nonebot import get_driver
|
||||||
|
|
||||||
|
driver_config = get_driver().config
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
value = getattr(driver_config, name.lower(), "")
|
||||||
|
return str(value) if value is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_setting(name: str) -> str:
|
||||||
|
"""按单个配置名读取系统环境变量,再读取 NoneBot 配置对象。"""
|
||||||
|
|
||||||
|
return os.getenv(name, "") or _read_nonebot_config(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_setting(*names: str, default: str = "") -> str:
|
||||||
|
"""按业务优先级读取多个兼容配置名。"""
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
value = _read_setting(name)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
model_config = SettingsConfigDict(extra="ignore")
|
model_config = SettingsConfigDict(extra="ignore")
|
||||||
|
|
||||||
@@ -112,17 +142,28 @@ class Config(BaseSettings):
|
|||||||
SPECIAL_PROBABILITY_USERS: list = [] # 100%抽到SSR或SP的用户列表
|
SPECIAL_PROBABILITY_USERS: list = [] # 100%抽到SSR或SP的用户列表
|
||||||
|
|
||||||
# Web后台管理配置
|
# Web后台管理配置
|
||||||
WEB_ADMIN_TOKEN: str = os.getenv("WEB_ADMIN_TOKEN", "onmyoji_admin_token_2024")
|
WEB_ADMIN_TOKEN: str = Field(
|
||||||
WEB_ADMIN_PORT: int = int(os.getenv("WEB_ADMIN_PORT", "8080"))
|
default_factory=lambda: _first_setting("WEB_ADMIN_TOKEN", default="onmyoji_admin_token_2024")
|
||||||
|
)
|
||||||
|
WEB_ADMIN_PORT: int = Field(default_factory=lambda: int(_first_setting("WEB_ADMIN_PORT", default="8080")))
|
||||||
|
|
||||||
# 蛋定服务器对接配置
|
# 蛋定服务器对接配置
|
||||||
DD_API_HOST: str = "https://api.danding.vip/DD/"
|
DD_API_HOST: str = "https://api.danding.vip/DD/"
|
||||||
GACHA_API_HOST: str = os.getenv("DANDING_GACHA_API_HOST", "https://api.danding.vip/bot/gacha")
|
GACHA_API_HOST: str = Field(
|
||||||
BOT_TOKEN: str = os.getenv(
|
default_factory=lambda: _first_setting(
|
||||||
"DANDING_BOT_TOKEN",
|
"DANDING_GACHA_API_HOST",
|
||||||
os.getenv("ONMYOJI_BOT_TOKEN", os.getenv("DANDING_API_TOKEN", os.getenv("BOT_TOKEN", ""))),
|
default="https://api.danding.vip/bot/gacha",
|
||||||
)
|
)
|
||||||
BOT_USER_ID: str = os.getenv("DANDING_BOT_USER", "1424473282")
|
)
|
||||||
|
BOT_TOKEN: str = Field(
|
||||||
|
default_factory=lambda: _first_setting(
|
||||||
|
"DANDING_BOT_TOKEN",
|
||||||
|
"ONMYOJI_BOT_TOKEN",
|
||||||
|
"DANDING_API_TOKEN",
|
||||||
|
"BOT_TOKEN",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
BOT_USER_ID: str = Field(default_factory=lambda: _first_setting("DANDING_BOT_USER", default="1424473282"))
|
||||||
|
|
||||||
# 时区
|
# 时区
|
||||||
TIMEZONE: str = "Asia/Shanghai"
|
TIMEZONE: str = "Asia/Shanghai"
|
||||||
|
|||||||
@@ -115,6 +115,59 @@ def success_data(data):
|
|||||||
return {"code": 200, "message": "", "data": data}
|
return {"code": 200, "message": "", "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_reads_token_from_nonebot_driver_config(monkeypatch):
|
||||||
|
"""NoneBot 只把 .env 注入 driver.config 时,抽卡 API token 不能为空。"""
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"DANDING_BOT_TOKEN",
|
||||||
|
"ONMYOJI_BOT_TOKEN",
|
||||||
|
"DANDING_API_TOKEN",
|
||||||
|
"BOT_TOKEN",
|
||||||
|
"WEB_ADMIN_TOKEN",
|
||||||
|
"DANDING_BOT_USER",
|
||||||
|
"DANDING_GACHA_API_HOST",
|
||||||
|
):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
||||||
|
class FakeDriver:
|
||||||
|
config = types.SimpleNamespace(
|
||||||
|
danding_api_token="driver-api-token",
|
||||||
|
web_admin_token="driver-admin-token",
|
||||||
|
danding_bot_user="driver-user",
|
||||||
|
danding_gacha_api_host="http://driver.test/bot/gacha/",
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_nonebot = types.ModuleType("nonebot")
|
||||||
|
fake_nonebot.get_driver = lambda: FakeDriver()
|
||||||
|
monkeypatch.setitem(sys.modules, "nonebot", fake_nonebot)
|
||||||
|
|
||||||
|
test_config = Config()
|
||||||
|
|
||||||
|
assert test_config.BOT_TOKEN == "driver-api-token"
|
||||||
|
assert test_config.WEB_ADMIN_TOKEN == "driver-admin-token"
|
||||||
|
assert test_config.BOT_USER_ID == "driver-user"
|
||||||
|
assert test_config.GACHA_API_HOST == "http://driver.test/bot/gacha"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_keeps_explicit_bot_token_priority(monkeypatch):
|
||||||
|
"""显式抽卡 token 优先于兼容旧 token,避免多 token 配置时误读。"""
|
||||||
|
|
||||||
|
for key in ("DANDING_BOT_TOKEN", "ONMYOJI_BOT_TOKEN", "DANDING_API_TOKEN", "BOT_TOKEN"):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
||||||
|
class FakeDriver:
|
||||||
|
config = types.SimpleNamespace(
|
||||||
|
danding_bot_token="driver-bot-token",
|
||||||
|
danding_api_token="driver-api-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_nonebot = types.ModuleType("nonebot")
|
||||||
|
fake_nonebot.get_driver = lambda: FakeDriver()
|
||||||
|
monkeypatch.setitem(sys.modules, "nonebot", fake_nonebot)
|
||||||
|
|
||||||
|
assert Config().BOT_TOKEN == "driver-bot-token"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shikigami_cache_and_draw_send_auth_to_xapi(fake_aiohttp):
|
async def test_shikigami_cache_and_draw_send_auth_to_xapi(fake_aiohttp):
|
||||||
responses, calls = fake_aiohttp
|
responses, calls = fake_aiohttp
|
||||||
|
|||||||
Reference in New Issue
Block a user