From cb94794e386dffac9768e8d11e25366504a0090e Mon Sep 17 00:00:00 2001 From: "Mr.Xia" <1424473282@qq.com> Date: Sat, 15 Aug 2026 20:50:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(qqpush):=20=E7=A7=81=E8=81=8A=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E6=8E=A5=E5=8F=A3=E4=B8=8E=E6=8A=BD=E5=8D=A1API=20tok?= =?UTF-8?q?en=E8=AF=BB=E5=8F=96=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- danding_bot/plugins/auto_recall/__init__.py | 15 ++- danding_bot/plugins/chatai/__init__.py | 13 ++- danding_bot/plugins/danding_qqpush/api.py | 109 ++++++++++++++++++ danding_bot/plugins/danding_qqpush/sender.py | 46 +++++++- .../group_horse_racing/message_service.py | 4 +- danding_bot/plugins/onmyoji_gacha/config.py | 85 ++++++++++---- tests/test_onmyoji_gacha_http_api.py | 53 +++++++++ 7 files changed, 292 insertions(+), 33 deletions(-) diff --git a/danding_bot/plugins/auto_recall/__init__.py b/danding_bot/plugins/auto_recall/__init__.py index f4b281e..4a94a91 100644 --- a/danding_bot/plugins/auto_recall/__init__.py +++ b/danding_bot/plugins/auto_recall/__init__.py @@ -30,10 +30,21 @@ def _track_task(task: asyncio.Task) -> None: async def handle_api_result( bot: Bot, exception: Optional[Exception], api: str, data: Dict[str, Any], result: Any ): - """拦截发送消息API调用,监控发出的消息""" - if api not in ("send_msg", "send_group_msg", "send_private_msg") or exception: + """拦截发送消息API调用,仅对群消息进行撤回,私人消息不撤回""" + if exception: 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") if not message_id: return diff --git a/danding_bot/plugins/chatai/__init__.py b/danding_bot/plugins/chatai/__init__.py index b04f712..3f63cfc 100644 --- a/danding_bot/plugins/chatai/__init__.py +++ b/danding_bot/plugins/chatai/__init__.py @@ -169,12 +169,13 @@ async def handle_message(event: MessageEvent, bot: Bot): except OSError: pass - # 保存task引用防止GC回收 - task = asyncio.create_task( - _delete_message_after_delay(bot, sent_message["message_id"]) - ) - _recall_tasks.add(task) - task.add_done_callback(_recall_tasks.discard) + # 仅群消息撤回,私人消息不撤回 + if event.message_type != "private": + task = asyncio.create_task( + _delete_message_after_delay(bot, sent_message["message_id"]) + ) + _recall_tasks.add(task) + task.add_done_callback(_recall_tasks.discard) except FinishedException: raise diff --git a/danding_bot/plugins/danding_qqpush/api.py b/danding_bot/plugins/danding_qqpush/api.py index 845aec1..a6cafbc 100644 --- a/danding_bot/plugins/danding_qqpush/api.py +++ b/danding_bot/plugins/danding_qqpush/api.py @@ -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): """推送响应模型""" @@ -169,4 +191,91 @@ def create_routes(token: str, config: Config): 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 diff --git a/danding_bot/plugins/danding_qqpush/sender.py b/danding_bot/plugins/danding_qqpush/sender.py index e587a92..1ae16eb 100644 --- a/danding_bot/plugins/danding_qqpush/sender.py +++ b/danding_bot/plugins/danding_qqpush/sender.py @@ -80,11 +80,55 @@ class MessageSender: 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, text: str, qq: Optional[int] = None) -> dict: """向指定群发送纯文本消息,可选择 @ 成员。""" 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() diff --git a/danding_bot/plugins/group_horse_racing/message_service.py b/danding_bot/plugins/group_horse_racing/message_service.py index 8c881df..53f0707 100644 --- a/danding_bot/plugins/group_horse_racing/message_service.py +++ b/danding_bot/plugins/group_horse_racing/message_service.py @@ -46,9 +46,9 @@ class MessageService: self.last_messages[scope] = {} 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) - if recall_delay > 0: + if recall_delay > 0 and is_group: task = asyncio.create_task( self._schedule_recall(bot, scope, message_id, recall_delay) ) diff --git a/danding_bot/plugins/onmyoji_gacha/config.py b/danding_bot/plugins/onmyoji_gacha/config.py index a01fade..c382cb8 100644 --- a/danding_bot/plugins/onmyoji_gacha/config.py +++ b/danding_bot/plugins/onmyoji_gacha/config.py @@ -1,10 +1,40 @@ -from pydantic import field_validator, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict -import os -import logging +from pydantic import Field, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +import os +import logging 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): model_config = SettingsConfigDict(extra="ignore") @@ -112,27 +142,38 @@ class Config(BaseSettings): SPECIAL_PROBABILITY_USERS: list = [] # 100%抽到SSR或SP的用户列表 # Web后台管理配置 - WEB_ADMIN_TOKEN: str = os.getenv("WEB_ADMIN_TOKEN", "onmyoji_admin_token_2024") - WEB_ADMIN_PORT: int = int(os.getenv("WEB_ADMIN_PORT", "8080")) + WEB_ADMIN_TOKEN: str = Field( + 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/" - GACHA_API_HOST: str = os.getenv("DANDING_GACHA_API_HOST", "https://api.danding.vip/bot/gacha") - BOT_TOKEN: str = os.getenv( - "DANDING_BOT_TOKEN", - os.getenv("ONMYOJI_BOT_TOKEN", os.getenv("DANDING_API_TOKEN", os.getenv("BOT_TOKEN", ""))), - ) - BOT_USER_ID: str = os.getenv("DANDING_BOT_USER", "1424473282") + # 蛋定服务器对接配置 + DD_API_HOST: str = "https://api.danding.vip/DD/" + GACHA_API_HOST: str = Field( + default_factory=lambda: _first_setting( + "DANDING_GACHA_API_HOST", + default="https://api.danding.vip/bot/gacha", + ) + ) + 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" - - @field_validator("GACHA_API_HOST") - @classmethod - def validate_gacha_api_host(cls, value): - if not value: - raise ValueError("GACHA_API_HOST cannot be empty") - return value.rstrip("/") + TIMEZONE: str = "Asia/Shanghai" + + @field_validator("GACHA_API_HOST") + @classmethod + def validate_gacha_api_host(cls, value): + if not value: + raise ValueError("GACHA_API_HOST cannot be empty") + return value.rstrip("/") @model_validator(mode="after") def _warn_default_token(self): diff --git a/tests/test_onmyoji_gacha_http_api.py b/tests/test_onmyoji_gacha_http_api.py index 19cc3d6..736318d 100644 --- a/tests/test_onmyoji_gacha_http_api.py +++ b/tests/test_onmyoji_gacha_http_api.py @@ -115,6 +115,59 @@ def success_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 async def test_shikigami_cache_and_draw_send_auth_to_xapi(fake_aiohttp): responses, calls = fake_aiohttp