diff --git a/app/api/__init__.py b/app/api/__init__.py index 0843a3a5b..3ce03611f 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -27,6 +27,8 @@ from .history import router as history_router from .info import router as info_router from .ocr import router as ocr_router +from .openclaw_qq import router as openclaw_qq_router +from .openclaw_weixin import router as openclaw_weixin_router from .plan import router as plan_router from .queue import router as queue_router from .scripts import router as scripts_router @@ -53,5 +55,7 @@ "setting_router", "update_router", "ocr_router", + "openclaw_qq_router", + "openclaw_weixin_router", "qr_login_router", ] diff --git a/app/api/openclaw_qq.py b/app/api/openclaw_qq.py new file mode 100644 index 000000000..ce47a0fee --- /dev/null +++ b/app/api/openclaw_qq.py @@ -0,0 +1,132 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + +"""QQ 官方机器人扫码绑定 API。""" + +from fastapi import APIRouter, Body + +from app.models.schema import ( + OpenClawQQQrCheckIn, + OpenClawQQQrCheckOut, + OpenClawQQQrStartOut, + OpenClawQQStatusOut, + OutBase, +) +from app.services.openclaw_qq import openclaw_qq_manager + +router = APIRouter(prefix="/api/setting/openclaw-qq", tags=["QQ 官方机器人通知"]) + + +@router.post( + "/status", + summary="查询 QQ 官方机器人绑定状态", + response_model=OpenClawQQStatusOut, +) +async def get_status() -> OpenClawQQStatusOut: + """返回 QQ 绑定状态,不返回协议凭据。""" + + try: + state = openclaw_qq_manager.status() + except Exception as exc: + return OpenClawQQStatusOut( + code=500, + status="error", + message=f"查询 QQ 登录状态失败: {type(exc).__name__}: {exc}", + ) + return OpenClawQQStatusOut( + enabled=state.enabled, + connected=state.connected, + state=state.state, + message=state.message, + ) + + +@router.post( + "/login/start", + summary="创建 QQ 官方机器人登录二维码", + response_model=OpenClawQQQrStartOut, +) +async def start_login() -> OpenClawQQQrStartOut: + """创建二维码;App ID 和客户端密钥只在后台登录确认后保存。""" + + try: + result = await openclaw_qq_manager.start_login() + except ValueError as exc: + return OpenClawQQQrStartOut(code=400, status="error", message=str(exc)) + except Exception as exc: + return OpenClawQQQrStartOut( + code=500, + status="error", + message=f"创建 QQ 二维码失败: {type(exc).__name__}: {exc}", + ) + return OpenClawQQQrStartOut( + sessionId=result.session_id, + qrUrl=result.qr_url, + message="请使用 QQ 扫描二维码", + ) + + +@router.post( + "/login/check", + summary="查询 QQ 官方机器人登录状态", + response_model=OpenClawQQQrCheckOut, +) +async def check_login( + body: OpenClawQQQrCheckIn = Body(...), +) -> OpenClawQQQrCheckOut: + """轮询二维码状态;确认后自动保存 QQ 机器人凭据。""" + + try: + result = await openclaw_qq_manager.check_login(session_id=body.sessionId) + except Exception as exc: + return OpenClawQQQrCheckOut( + code=500, + status="error", + sessionId=body.sessionId, + state="error", + message=f"查询 QQ 登录状态失败: {type(exc).__name__}: {exc}", + ) + return OpenClawQQQrCheckOut( + sessionId=result.session_id, + state=result.state, + connected=result.connected, + message=result.message, + ) + + +@router.post( + "/unbind", + summary="解除 QQ 官方机器人绑定", + response_model=OutBase, +) +async def unbind() -> OutBase: + """解除绑定并清理本地保存的 QQ 协议状态。""" + + try: + await openclaw_qq_manager.unbind() + except Exception as exc: + return OutBase( + code=500, + status="error", + message=f"解除 QQ 绑定失败: {type(exc).__name__}: {exc}", + ) + return OutBase(message="QQ 官方机器人已解除绑定") diff --git a/app/api/openclaw_weixin.py b/app/api/openclaw_weixin.py new file mode 100644 index 000000000..dffa86f27 --- /dev/null +++ b/app/api/openclaw_weixin.py @@ -0,0 +1,137 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + +"""微信 Claw 扫码绑定 API。""" + +from fastapi import APIRouter, Body + +from app.models.schema import ( + OpenClawWeixinQrCheckIn, + OpenClawWeixinQrCheckOut, + OpenClawWeixinQrStartOut, + OpenClawWeixinStatusOut, + OutBase, +) +from app.services.openclaw_weixin import openclaw_weixin_manager + +router = APIRouter( + prefix="/api/setting/openclaw-weixin", tags=["微信 Claw 通知"] +) + + +@router.post( + "/status", + summary="查询微信 Claw 绑定状态", + response_model=OpenClawWeixinStatusOut, +) +async def get_status() -> OpenClawWeixinStatusOut: + """返回微信绑定状态,不返回协议凭据。""" + + try: + state = openclaw_weixin_manager.status() + except Exception as exc: + return OpenClawWeixinStatusOut( + code=500, + status="error", + message=f"查询微信绑定状态失败: {type(exc).__name__}: {exc}", + ) + return OpenClawWeixinStatusOut( + enabled=state.enabled, + connected=state.connected, + state=state.state, + message=state.message, + ) + + +@router.post( + "/login/start", + summary="创建微信 Claw 登录二维码", + response_model=OpenClawWeixinQrStartOut, +) +async def start_login() -> OpenClawWeixinQrStartOut: + """创建二维码;Bot Token 等凭据只在后台登录确认后保存。""" + + try: + result = await openclaw_weixin_manager.start_login() + except ValueError as exc: + return OpenClawWeixinQrStartOut(code=400, status="error", message=str(exc)) + except Exception as exc: + return OpenClawWeixinQrStartOut( + code=500, + status="error", + message=f"创建微信二维码失败: {type(exc).__name__}: {exc}", + ) + return OpenClawWeixinQrStartOut( + sessionId=result.session_id, + qrUrl=result.qr_url, + message="请使用微信扫描二维码", + ) + + +@router.post( + "/login/check", + summary="查询微信 Claw 登录状态", + response_model=OpenClawWeixinQrCheckOut, +) +async def check_login( + body: OpenClawWeixinQrCheckIn = Body(...), +) -> OpenClawWeixinQrCheckOut: + """查询二维码状态;确认后自动保存账号凭据。""" + + try: + result = await openclaw_weixin_manager.check_login( + session_id=body.sessionId, + verify_code=body.verifyCode, + ) + except Exception as exc: + return OpenClawWeixinQrCheckOut( + code=500, + status="error", + sessionId=body.sessionId, + state="error", + message=f"查询微信登录状态失败: {type(exc).__name__}: {exc}", + ) + return OpenClawWeixinQrCheckOut( + sessionId=result.session_id, + state=result.state, + connected=result.connected, + message=result.message, + ) + + +@router.post( + "/unbind", + summary="解除微信 Claw 绑定", + response_model=OutBase, +) +async def unbind() -> OutBase: + """解除绑定并清理本地保存的微信协议状态。""" + + try: + await openclaw_weixin_manager.unbind() + except Exception as exc: + return OutBase( + code=500, + status="error", + message=f"解除微信绑定失败: {type(exc).__name__}: {exc}", + ) + return OutBase(message="微信 Claw 已解除绑定") diff --git a/app/core/notify.py b/app/core/notify.py index 7c732cd71..0cb4e5461 100644 --- a/app/core/notify.py +++ b/app/core/notify.py @@ -100,6 +100,18 @@ def koishi_content(self) -> str: return self.koishi_text return f"{self.title}\n\n{self.signed_text}" + @property + def openclaw_weixin_content(self) -> str: + """返回微信(iLink)正文。""" + + return f"{self.title}\n\n{self.signed_text}" + + @property + def openclaw_qq_content(self) -> str: + """返回 QQ 官方机器人正文。""" + + return f"{self.title}\n\n{self.signed_text}" + @property def system_content(self) -> str: """返回系统通知正文。""" @@ -117,6 +129,8 @@ class NotifyTarget: serverchan_key: str | None = None webhooks: Iterable[tuple[str, Any]] = () koishi: bool = False + openclaw_weixin: bool = False + openclaw_qq: bool = False empty_policy: EmptyPolicy = "send" @@ -166,6 +180,8 @@ def global_target( ), webhooks=_webhooks(Config.Notify_CustomWebhooks), koishi=bool(Config.get("Notify", "IfKoishiSupport")), + openclaw_weixin=bool(Config.get("Notify", "IfOpenClawWeixin")), + openclaw_qq=bool(Config.get("Notify", "IfOpenClawQQ")), empty_policy=empty_policy, ) @@ -257,6 +273,10 @@ def target_channel_names(target: NotifyTarget) -> tuple[str, ...]: ) if target.koishi: names.append(f"{target.name} Koishi") + if target.openclaw_weixin: + names.append(f"{target.name} 微信(iLink)") + if target.openclaw_qq: + names.append(f"{target.name} QQ(官方机器人)") return tuple(names) @@ -412,6 +432,24 @@ def miss(channel: str) -> None: lambda: Notify.send_koishi(payload.koishi_content), ) + if target.openclaw_weixin: + await attempt( + f"{target.name} 微信(iLink)", + lambda: Notify.send_openclaw_weixin( + title=payload.title, + content=payload.openclaw_weixin_content, + ), + ) + + if target.openclaw_qq: + await attempt( + f"{target.name} QQ(官方机器人)", + lambda: Notify.send_openclaw_qq( + title=payload.title, + content=payload.openclaw_qq_content, + ), + ) + return DispatchResult( attempted=attempted, succeeded=tuple(succeeded), diff --git a/app/models/config.py b/app/models/config.py index 3c0f4363d..4a87956bf 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -3852,6 +3852,41 @@ def __init__(self): ) ## Koishi Token self.Notify_KoishiToken = ConfigItem("Notify", "KoishiToken", "") + ## 是否启用微信 Claw 通知(凭据由扫码登录流程管理) + self.Notify_IfOpenClawWeixin = ConfigItem( + "Notify", "IfOpenClawWeixin", False, BoolValidator() + ) + ## 是否启用 QQ 官方机器人通知(凭据由扫码登录流程管理) + self.Notify_IfOpenClawQQ = ConfigItem( + "Notify", "IfOpenClawQQ", False, BoolValidator() + ) + ## QQ 官方机器人应用 ID(由扫码登录响应返回) + self.Notify_OpenClawQQAppId = ConfigItem("Notify", "OpenClawQQAppId", "") + ## QQ 官方机器人客户端密钥(由扫码登录响应返回) + self.Notify_OpenClawQQClientSecret = ConfigItem( + "Notify", "OpenClawQQClientSecret", "", EncryptValidator() + ) + ## QQ 官方机器人目标用户 OpenID(由扫码登录响应返回) + self.Notify_OpenClawQQTargetOpenId = ConfigItem( + "Notify", "OpenClawQQTargetOpenId", "" + ) + self.Notify_OpenClawWeixinServerAddress = ConfigItem( + "Notify", + "OpenClawWeixinServerAddress", + "https://ilinkai.weixin.qq.com", + URLValidator(schemes=["https"]), + ) + self.Notify_OpenClawWeixinBotToken = ConfigItem( + "Notify", "OpenClawWeixinBotToken", "", EncryptValidator() + ) + ## 微信 Claw 账号 ID(由二维码登录响应返回) + self.Notify_OpenClawWeixinAccountId = ConfigItem( + "Notify", "OpenClawWeixinAccountId", "" + ) + ## 微信 Claw 用户 ID(由二维码登录响应返回) + self.Notify_OpenClawWeixinTargetUserId = ConfigItem( + "Notify", "OpenClawWeixinTargetUserId", "" + ) ## SMTP 服务器地址 self.Notify_SMTPServerAddress = ConfigItem("Notify", "SMTPServerAddress", "") ## 邮箱授权码 diff --git a/app/models/schema.py b/app/models/schema.py index fa1e0522b..ab7933a81 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -338,6 +338,12 @@ class GlobalConfig_Notify(BaseModel): default=None, description="Koishi服务器地址" ) KoishiToken: Optional[str] = Field(default=None, description="Koishi Token") + IfOpenClawWeixin: Optional[bool] = Field( + default=None, description="是否启用微信 Claw 通知" + ) + IfOpenClawQQ: Optional[bool] = Field( + default=None, description="是否启用 QQ 官方机器人通知" + ) SMTPServerAddress: Optional[str] = Field(default=None, description="SMTP服务器地址") AuthorizationCode: Optional[str] = Field(default=None, description="SMTP授权码") FromAddress: Optional[str] = Field(default=None, description="邮件发送地址") @@ -348,6 +354,67 @@ class GlobalConfig_Notify(BaseModel): ServerChanKey: Optional[str] = Field(default=None, description="ServerChan推送密钥") +class OpenClawWeixinQrStartOut(OutBase): + """微信 Claw 二维码创建响应。""" + + sessionId: str = Field(default="", description="二维码登录会话 ID") + qrUrl: str = Field(default="", description="用于生成二维码的登录链接") + + +class OpenClawWeixinQrCheckIn(BaseModel): + """微信 Claw 二维码状态查询请求。""" + + sessionId: str = Field(..., min_length=1, description="二维码登录会话 ID") + verifyCode: Optional[str] = Field( + default=None, max_length=32, description="微信要求时输入的配对码" + ) + + +class OpenClawWeixinQrCheckOut(OutBase): + """微信 Claw 二维码状态查询响应。""" + + sessionId: str = Field(default="", description="二维码登录会话 ID") + state: str = Field(default="", description="二维码状态") + connected: bool = Field(default=False, description="是否已完成账号绑定") + + +class OpenClawWeixinStatusOut(OutBase): + """微信 Claw 通知绑定状态,不返回任何凭据。""" + + enabled: bool = Field(default=False, description="是否启用微信 Claw 通知") + connected: bool = Field(default=False, description="是否已绑定微信账号") + state: str = Field(default="disconnected", description="当前连接状态") + + +class OpenClawQQQrStartOut(OutBase): + """QQ 官方机器人二维码创建响应。""" + + sessionId: str = Field(default="", description="二维码登录会话 ID") + qrUrl: str = Field(default="", description="用于生成二维码的登录链接") + + +class OpenClawQQQrCheckIn(BaseModel): + """QQ 官方机器人二维码状态查询请求。""" + + sessionId: str = Field(..., min_length=1, description="二维码登录会话 ID") + + +class OpenClawQQQrCheckOut(OutBase): + """QQ 官方机器人二维码状态查询响应。""" + + sessionId: str = Field(default="", description="二维码登录会话 ID") + state: str = Field(default="", description="二维码状态") + connected: bool = Field(default=False, description="是否已完成账号绑定") + + +class OpenClawQQStatusOut(OutBase): + """QQ 官方机器人通知绑定状态,不返回任何凭据。""" + + enabled: bool = Field(default=False, description="是否启用 QQ 官方机器人通知") + connected: bool = Field(default=False, description="是否已绑定 QQ 官方机器人") + state: str = Field(default="disconnected", description="当前连接状态") + + class GlobalConfig_Update(BaseModel): IfAutoUpdate: Optional[bool] = Field(default=None, description="是否自动更新") Source: Optional[Literal["GitHub", "MirrorChyan", "AutoSite", "CNB"]] = Field( diff --git a/app/services/notification.py b/app/services/notification.py index a87f17c71..c98333daa 100644 --- a/app/services/notification.py +++ b/app/services/notification.py @@ -246,6 +246,42 @@ async def ServerChanPush(self, title: str, content: str, send_key: str) -> None: else: raise Exception(f"ServerChan 推送通知失败: {response.text}") + async def send_openclaw_weixin(self, title: str, content: str) -> None: + """通过微信 Claw 通道推送通知。 + + 登录凭据和会话上下文由扫码登录管理器维护,通知层不读取或暴露协议 + 细节;长文本拆分、业务错误和上下文失效也由管理器统一处理。 + + Args: + title: 通知标题。 + content: 已渲染的通知正文。 + + Raises: + ValueError: 尚未绑定微信账号时抛出。 + RuntimeError: 网关返回 HTTP 或业务错误时抛出。 + """ + from app.services.openclaw_weixin import openclaw_weixin_manager + + await openclaw_weixin_manager.send(title=title, content=content) + + async def send_openclaw_qq(self, title: str, content: str) -> None: + """通过 QQ 官方机器人通道推送通知。 + + 登录凭据由扫码登录管理器维护,通知层不读取或暴露协议细节;长文本 + 拆分、业务错误和访问令牌刷新也由管理器统一处理。 + + Args: + title: 通知标题。 + content: 已渲染的通知正文。 + + Raises: + ValueError: 尚未绑定 QQ 官方机器人时抛出。 + RuntimeError: 官方接口返回 HTTP 或业务错误时抛出。 + """ + from app.services.openclaw_qq import openclaw_qq_manager + + await openclaw_qq_manager.send(title=title, content=content) + async def WebhookPush(self, title: str, content: str, webhook: Webhook) -> None: """ Webhook 推送通知 diff --git a/app/services/openclaw_qq.py b/app/services/openclaw_qq.py new file mode 100644 index 000000000..0f2dc1db1 --- /dev/null +++ b/app/services/openclaw_qq.py @@ -0,0 +1,740 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + +"""QQ 官方机器人扫码绑定、Token 管理和 C2C 出站消息服务。 + +用户只需要扫描 QQ 官方机器人提供的二维码。App ID、客户端密钥和目标用户 +OpenID 都由官方扫码流程返回,并由本模块负责保存和换取短期访问令牌;这些 +协议凭据不进入公开设置 schema。 +""" + +from __future__ import annotations + +import asyncio +import base64 +import secrets +import uuid +from dataclasses import dataclass +from time import monotonic +from typing import Any +from urllib.parse import quote + +import httpx + +from app.utils import LazyProxy, get_logger +from app.utils.platform import secret as platform_secret + +Config = LazyProxy("app.core", "Config") +logger = get_logger("QQ官方机器人") + +PORTAL_BASE_URL = "https://q.qq.com" +API_BASE_URL = "https://api.sgroup.qq.com" +TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken" +QR_CONNECT_URL = "https://q.qq.com/qqbot/openclaw/connect.html" +QR_SESSION_TTL_SECONDS = 5 * 60 +QR_REQUEST_TIMEOUT_SECONDS = 15 +API_REQUEST_TIMEOUT_SECONDS = 15 +TEXT_CHUNK_LIMIT = 3800 +MESSAGE_SEQUENCE_MAX = 0xFFFFFFFF +USER_AGENT = "AUTO-MAS QQ Official Bot" + + +@dataclass +class _QrSession: + """内存中的一次 QQ 官方机器人二维码登录会话。""" + + task_id: str + aes_key: bytes + created_at: float + + @property + def expired(self) -> bool: + return monotonic() - self.created_at >= QR_SESSION_TTL_SECONDS + + +@dataclass +class _RuntimeCredentials: + """无法使用平台密文存储时,仅保留在本次进程内的凭据。""" + + app_id: str + client_secret: str + user_openid: str + + +class RemoteHTTPError(RuntimeError): + """远端返回明确 HTTP 状态码的请求错误。""" + + def __init__(self, status_code: int, message: str) -> None: + self.status_code = status_code + super().__init__(message) + + +@dataclass(frozen=True) +class QrStartResult: + """创建二维码后的公开结果。""" + + session_id: str + qr_url: str + + +@dataclass(frozen=True) +class QrCheckResult: + """二维码轮询后的公开结果。""" + + session_id: str + state: str + message: str + connected: bool = False + + +@dataclass(frozen=True) +class QQStatus: + """不包含任何凭据的 QQ 绑定状态。""" + + enabled: bool + connected: bool + state: str + message: str + + +def split_text(text: str, limit: int = TEXT_CHUNK_LIMIT) -> list[str]: + """将通知文本按字符上限拆分,并尽量在换行处断开。""" + + if limit < 1: + raise ValueError("文本分段长度必须大于 0") + if not text: + return [""] + if len(text) <= limit: + return [text] + + chunks: list[str] = [] + remaining = text + while len(remaining) > limit: + boundary = remaining.rfind("\n", 0, limit + 1) + if boundary <= 0: + boundary = limit + chunks.append(remaining[:boundary].rstrip("\n")) + remaining = remaining[boundary:].lstrip("\n") + if remaining: + chunks.append(remaining) + return chunks + + +def _as_int(value: Any) -> int | None: + """把官方接口可能返回的数字字符串统一为整数。""" + + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _client_kwargs(timeout: float) -> dict[str, Any]: + """生成 HTTP 客户端参数,避免环境代理干扰官方接口。""" + + kwargs: dict[str, Any] = { + "timeout": timeout, + "trust_env": False, + "follow_redirects": True, + } + proxy = Config.proxy + if proxy is not None: + kwargs["proxy"] = proxy + return kwargs + + +def _headers( + *, app_id: str | None = None, access_token: str | None = None +) -> dict[str, str]: + """构造 QQ 官方接口公共请求头。""" + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + } + if access_token: + headers["Authorization"] = f"QQBot {access_token}" + if app_id: + headers["X-Union-Appid"] = app_id + return headers + + +def _business_code(payload: dict[str, Any]) -> int | None: + """读取不同 QQ 接口使用的业务错误码字段。""" + + for name in ("code", "retcode", "errcode", "errno"): + if name in payload: + code = _as_int(payload.get(name)) + if code is not None: + return code + return None + + +def _business_message(payload: dict[str, Any]) -> str: + """返回不包含凭据的官方错误描述。""" + + for name in ("message", "msg", "errmsg", "error_description"): + value = payload.get(name) + if isinstance(value, str) and value.strip(): + return value.strip() + return "未知错误" + + +def _decrypt_client_secret(encrypted: str, key: bytes) -> str: + """解密官方扫码返回的 AES-256-GCM 客户端密钥。""" + + if len(key) != 32: + raise ValueError("QQ 登录会话密钥长度无效") + try: + raw = base64.b64decode(encrypted, validate=True) + except (ValueError, TypeError) as exc: + raise ValueError("QQ 登录返回的客户端密钥格式无效") from exc + if len(raw) <= 12 + 16: + raise ValueError("QQ 登录返回的客户端密钥长度无效") + + # pycryptodome 已是项目的直接依赖;延迟导入可让普通启动不引入密码学模块。 + from Crypto.Cipher import AES + + iv = raw[:12] + ciphertext = raw[12:-16] + tag = raw[-16:] + try: + cipher = AES.new(key, AES.MODE_GCM, nonce=iv) + secret = cipher.decrypt_and_verify(ciphertext, tag) + except (ValueError, TypeError) as exc: + raise ValueError("QQ 登录返回的客户端密钥无法解密") from exc + try: + result = secret.decode("utf-8").strip() + except UnicodeDecodeError as exc: + raise ValueError("QQ 登录返回的客户端密钥编码无效") from exc + if not result: + raise ValueError("QQ 登录返回的客户端密钥为空") + return result + + +class OpenClawQQManager: + """单账号 QQ 官方机器人通知管理器。""" + + def __init__(self) -> None: + self._sessions: dict[str, _QrSession] = {} + self._session_lock = asyncio.Lock() + self._session_generation = 0 + self._config_lock = asyncio.Lock() + self._send_lock = asyncio.Lock() + self._credential_lock = asyncio.Lock() + self._hooks_bound = False + self._runtime_credentials: _RuntimeCredentials | None = None + self._secret_storage_available: bool | None = None + self._access_token = "" + self._access_token_expires_at = 0.0 + self._msg_seq = 0 + + def bind_config_hooks(self) -> None: + """绑定通知开关变化,关闭时立即丢弃本地访问令牌。""" + + if self._hooks_bound: + return + Config.bind("Notify", "IfOpenClawQQ", self._on_enabled_changed) + self._hooks_bound = True + + async def start(self) -> None: + """在后端启动时绑定配置钩子;访问令牌按需获取。""" + + self.bind_config_hooks() + + async def stop(self) -> None: + """停止服务并清理短期访问令牌和临时二维码。""" + + async with self._session_lock: + self._sessions.clear() + self._session_generation += 1 + self._invalidate_access_token() + + async def _on_enabled_changed(self, enabled: Any) -> None: + if not bool(enabled): + self._invalidate_access_token() + + def _enabled(self) -> bool: + try: + return bool(Config.get("Notify", "IfOpenClawQQ")) + except (AttributeError, RuntimeError): + return False + + def _config_value(self, name: str, default: Any = "") -> Any: + """读取内部配置;平台不支持密文时按未保存处理。""" + + try: + return Config.get("Notify", name) + except Exception as exc: + if platform_secret.is_secret_storage_error(exc): + return default + raise + + def _can_persist_secrets(self) -> bool: + """确认是否可以使用配置层的 Windows DPAPI 密文存储。""" + + if self._secret_storage_available is None: + self._secret_storage_available = platform_secret.supports_secret_storage() + return self._secret_storage_available + + def _credentials(self) -> tuple[str, str, str]: + if self._runtime_credentials is not None: + runtime = self._runtime_credentials + return runtime.app_id, runtime.client_secret, runtime.user_openid + return ( + str(self._config_value("OpenClawQQAppId") or "").strip(), + str(self._config_value("OpenClawQQClientSecret") or "").strip(), + str(self._config_value("OpenClawQQTargetOpenId") or "").strip(), + ) + + def status(self) -> QQStatus: + """返回 QQ 绑定状态,不回传 App ID、Secret 或 OpenID。""" + + app_id, client_secret, user_openid = self._credentials() + connected = bool(app_id and client_secret and user_openid) + if connected: + state = "connected" + message = "QQ 官方机器人已绑定,通知可以发送" + else: + state = "disconnected" + message = "请扫码绑定 QQ 官方机器人" + return QQStatus( + enabled=self._enabled(), + connected=connected, + state=state, + message=message, + ) + + async def start_login(self) -> QrStartResult: + """创建官方扫码绑定任务并返回二维码链接。""" + + async with self._session_lock: + self._sessions.clear() + self._session_generation += 1 + generation = self._session_generation + aes_key = secrets.token_bytes(32) + + # 二维码接口可能等待十几秒;网络请求必须在会话锁外执行, + # 否则关闭二维码、重新绑定和解绑都会被阻塞。 + response = await self._request_json( + "POST", + f"{PORTAL_BASE_URL}/lite/create_bind_task", + body={"key": base64.b64encode(aes_key).decode("ascii")}, + headers=_headers(), + timeout=QR_REQUEST_TIMEOUT_SECONDS, + ) + if _business_code(response) not in (None, 0): + raise RuntimeError(f"QQ 二维码创建失败:{_business_message(response)}") + data = response.get("data") + task_id = ( + str(data.get("task_id") or "").strip() if isinstance(data, dict) else "" + ) + if not task_id: + raise RuntimeError("QQ 二维码响应缺少绑定任务") + + session_id = uuid.uuid4().hex + qr_url = f"{QR_CONNECT_URL}?task_id={quote(task_id, safe='')}&_wv=2" + async with self._session_lock: + if generation != self._session_generation: + raise RuntimeError("QQ 二维码登录会话已关闭,请重新生成") + self._sessions[session_id] = _QrSession( + task_id=task_id, + aes_key=aes_key, + created_at=monotonic(), + ) + return QrStartResult(session_id=session_id, qr_url=qr_url) + + async def check_login(self, session_id: str) -> QrCheckResult: + """轮询扫码绑定任务,完成后保存官方机器人凭据。""" + + async with self._session_lock: + session = self._sessions.get(session_id) + if session is None: + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话不存在,请重新生成", + ) + if session.expired: + self._sessions.pop(session_id, None) + return QrCheckResult( + session_id=session_id, + state="expired", + message="二维码已过期,请重新生成", + ) + generation = self._session_generation + task_id = session.task_id + aes_key = session.aes_key + + # 状态查询同样可能等待网络响应,不能在会话锁内轮询。 + try: + response = await self._request_json( + "POST", + f"{PORTAL_BASE_URL}/lite/poll_bind_result", + body={"task_id": task_id}, + headers=_headers(), + timeout=QR_REQUEST_TIMEOUT_SECONDS, + ) + except RemoteHTTPError as exc: + if 500 <= exc.status_code < 600: + # 5xx 通常是临时服务异常,让前端继续轮询并展示原因。 + return QrCheckResult( + session_id=session_id, + state="waiting", + message=str(exc), + ) + return QrCheckResult( + session_id=session_id, + state="error", + message=str(exc), + ) + except RuntimeError as exc: + # 网络错误、超时等没有明确 HTTP 状态码,允许前端重试。 + return QrCheckResult( + session_id=session_id, + state="waiting", + message=str(exc), + ) + + async with self._session_lock: + if ( + generation != self._session_generation + or self._sessions.get(session_id) is not session + ): + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话已关闭,请重新生成", + ) + + if _business_code(response) not in (None, 0): + return QrCheckResult( + session_id=session_id, + state="error", + message=f"查询 QQ 登录状态失败:{_business_message(response)}", + ) + data = response.get("data") + data = data if isinstance(data, dict) else {} + status = _as_int(data.get("status")) + if status in (None, 0): + return QrCheckResult( + session_id=session_id, + state="waiting", + message="请使用 QQ 扫描二维码", + ) + if status == 1: + return QrCheckResult( + session_id=session_id, + state="scanned", + message="已扫码,正在确认登录", + ) + if status == 3: + async with self._session_lock: + if ( + generation == self._session_generation + and self._sessions.get(session_id) is session + ): + self._sessions.pop(session_id, None) + return QrCheckResult( + session_id=session_id, + state="expired", + message="二维码已过期,请重新生成", + ) + if status != 2: + return QrCheckResult( + session_id=session_id, + state="error", + message=f"QQ 登录返回未知状态:{status}", + ) + + app_id = str(data.get("bot_appid") or "").strip() + encrypted_secret = str(data.get("bot_encrypt_secret") or "").strip() + user_openid = str(data.get("user_openid") or "").strip() + if not app_id or not encrypted_secret or not user_openid: + async with self._session_lock: + if ( + generation == self._session_generation + and self._sessions.get(session_id) is session + ): + self._sessions.pop(session_id, None) + return QrCheckResult( + session_id=session_id, + state="error", + message="登录确认响应缺少 QQ 机器人凭据,请重新扫码", + ) + try: + client_secret = _decrypt_client_secret(encrypted_secret, aes_key) + async with self._send_lock, self._credential_lock: + async with self._session_lock: + if ( + generation != self._session_generation + or self._sessions.get(session_id) is not session + ): + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话已关闭,请重新生成", + ) + self._sessions.pop(session_id, None) + await self._save_credentials_locked( + app_id=app_id, + client_secret=client_secret, + user_openid=user_openid, + ) + except (ValueError, RuntimeError) as exc: + async with self._session_lock: + if ( + generation == self._session_generation + and self._sessions.get(session_id) is session + ): + self._sessions.pop(session_id, None) + return QrCheckResult( + session_id=session_id, + state="error", + message=f"QQ 登录凭据处理失败:{exc}", + ) + + async with self._session_lock: + if generation != self._session_generation: + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话已关闭,请重新生成", + ) + return QrCheckResult( + session_id=session_id, + state="connected", + connected=True, + message="QQ 官方机器人扫码绑定成功", + ) + + async def unbind(self) -> None: + """解除绑定并清理本地保存的 QQ 协议状态。""" + + # 先在锁内使所有正在进行的二维码请求失效,再在锁外清理配置。 + async with self._session_lock: + self._sessions.clear() + self._session_generation += 1 + async with self._send_lock, self._credential_lock: + self._runtime_credentials = None + self._invalidate_access_token() + values = { + "IfOpenClawQQ": False, + "OpenClawQQAppId": "", + "OpenClawQQTargetOpenId": "", + } + if self._can_persist_secrets(): + values["OpenClawQQClientSecret"] = "" + async with self._config_lock: + await Config.update({"Notify": values}) + + async def send(self, title: str, content: str) -> None: + """通过官方 C2C 接口发送通知,长文本自动拆分。""" + + async with self._send_lock: + app_id, client_secret, user_openid = self._credentials() + if not app_id or not client_secret or not user_openid: + raise ValueError("请先在通知设置中扫码绑定 QQ 官方机器人") + + chunks = split_text(content, max(1, TEXT_CHUNK_LIMIT - 16)) + for index, chunk in enumerate(chunks, start=1): + if len(chunks) > 1: + chunk = f"[{index}/{len(chunks)}]\n{chunk}" + body = { + "msg_type": 0, + "msg_seq": self._next_msg_seq(), + "content": chunk, + } + await self._send_message_with_token( + app_id=app_id, + client_secret=client_secret, + user_openid=user_openid, + body=body, + ) + logger.success(f"QQ官方机器人通知推送成功: {title}") + + def _next_msg_seq(self) -> int: + """为每条 QQ 消息分配递增序号,避免不同通知重复使用同一序号。""" + + self._msg_seq = (self._msg_seq % MESSAGE_SEQUENCE_MAX) + 1 + return self._msg_seq + + async def _send_message_with_token( + self, + *, + app_id: str, + client_secret: str, + user_openid: str, + body: dict[str, Any], + ) -> None: + """发送单段消息,并在访问令牌过期时无感重试一次。""" + + for attempt in range(2): + access_token = await self._ensure_access_token(app_id, client_secret) + endpoint = f"{API_BASE_URL}/v2/users/{quote(user_openid, safe='')}/messages" + try: + response = await self._request_json( + "POST", + endpoint, + body=body, + headers=_headers(app_id=app_id, access_token=access_token), + timeout=API_REQUEST_TIMEOUT_SECONDS, + ) + except RuntimeError as exc: + if attempt == 0 and _is_token_error(exc): + self._invalidate_access_token() + continue + raise + code = _business_code(response) + if code not in (None, 0): + if attempt == 0 and code in (401, 401001, 11200, 11201): + self._invalidate_access_token() + continue + raise RuntimeError(f"QQ 通知发送失败:{_business_message(response)}") + return + + async def _ensure_access_token(self, app_id: str, client_secret: str) -> str: + """按需换取并缓存官方 access_token。""" + + now = monotonic() + if self._access_token and self._access_token_expires_at > now + 60: + return self._access_token + + response = await self._request_json( + "POST", + TOKEN_URL, + body={"appId": app_id, "clientSecret": client_secret}, + headers=_headers(), + timeout=API_REQUEST_TIMEOUT_SECONDS, + ) + code = _business_code(response) + if code not in (None, 0): + raise RuntimeError( + f"QQ access_token 获取失败:{_business_message(response)}" + ) + access_token = str(response.get("access_token") or "").strip() + if not access_token: + raise RuntimeError("QQ access_token 响应缺少访问令牌") + expires_in = _as_int(response.get("expires_in")) or 7200 + self._access_token = access_token + self._access_token_expires_at = monotonic() + max(60, expires_in) + return access_token + + def _invalidate_access_token(self) -> None: + self._access_token = "" + self._access_token_expires_at = 0.0 + + async def _save_credentials( + self, *, app_id: str, client_secret: str, user_openid: str + ) -> None: + """保存扫码返回的凭据,非 Windows 只保留本次运行所需数据。""" + + async with self._send_lock, self._credential_lock: + await self._save_credentials_locked( + app_id=app_id, + client_secret=client_secret, + user_openid=user_openid, + ) + + async def _save_credentials_locked( + self, *, app_id: str, client_secret: str, user_openid: str + ) -> None: + """在发送与凭据锁已取得时保存扫码返回的凭据。""" + + normalized = _RuntimeCredentials( + app_id=app_id.strip(), + client_secret=client_secret.strip(), + user_openid=user_openid.strip(), + ) + values = { + "OpenClawQQAppId": normalized.app_id, + "OpenClawQQTargetOpenId": normalized.user_openid, + } + async with self._config_lock: + if self._can_persist_secrets(): + try: + await Config.update( + { + "Notify": { + **values, + "OpenClawQQClientSecret": normalized.client_secret, + } + } + ) + except Exception as exc: + if not platform_secret.is_secret_storage_error(exc): + raise + self._secret_storage_available = False + if not self._can_persist_secrets(): + await Config.update({"Notify": values}) + logger.warning( + "当前平台不支持 Windows DPAPI,QQ 凭据仅保存在本次运行内;" + "应用重启后需要重新扫码" + ) + self._runtime_credentials = ( + None if self._can_persist_secrets() else normalized + ) + self._invalidate_access_token() + + async def _request_json( + self, + method: str, + url: str, + *, + body: dict[str, Any] | None, + headers: dict[str, str], + timeout: float, + ) -> dict[str, Any]: + """发起 QQ 请求并统一解析 HTTP/JSON 错误。""" + + try: + async with httpx.AsyncClient(**_client_kwargs(timeout)) as client: + response = await client.request( + method, + url, + json=body, + headers=headers, + ) + response.raise_for_status() + payload = response.json() + except httpx.HTTPStatusError as exc: + raise RemoteHTTPError( + exc.response.status_code, + f"QQ 官方机器人 HTTP 请求失败(状态码 {exc.response.status_code})", + ) from exc + except httpx.HTTPError as exc: + raise RuntimeError("QQ 官方机器人网络请求失败") from exc + except ValueError as exc: + raise RuntimeError("QQ 官方机器人响应不是合法 JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError("QQ 官方机器人响应格式无效") + return payload + + +def _is_token_error(error: RuntimeError) -> bool: + """判断 HTTP 错误是否可能表示访问令牌失效。""" + + return "状态码 401" in str(error) or "状态码 403" in str(error) + + +openclaw_qq_manager = OpenClawQQManager() diff --git a/app/services/openclaw_weixin.py b/app/services/openclaw_weixin.py new file mode 100644 index 000000000..a2b16be3d --- /dev/null +++ b/app/services/openclaw_weixin.py @@ -0,0 +1,792 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025 MoeSnowyFox +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + +"""微信 Claw/iLink 的扫码登录和出站消息服务。 + +用户只参与二维码登录。Bot Token、账号/用户 ID 都是协议层状态, +由本模块取得并保存,不作为设置页字段暴露给用户。 + +通知通道只在扫码或发送通知时请求 iLink,不在后台保持消息长轮询; +通知发送不需要会话上下文。 +""" + +from __future__ import annotations + +import asyncio +import base64 +import secrets +import uuid +from dataclasses import dataclass +from time import monotonic +from typing import Any +from urllib.parse import urlencode, urlparse + +import httpx + +from app.utils import LazyProxy, get_logger +from app.utils.platform import secret as platform_secret + +Config = LazyProxy("app.core", "Config") +logger = get_logger("微信Claw") + +DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com" +DEFAULT_BOT_TYPE = "3" +CLIENT_VERSION = "132104" # iLink 0x00MMNNPP, compatible with 2.4.8. +QR_SESSION_TTL_SECONDS = 5 * 60 +QR_REQUEST_TIMEOUT_SECONDS = 15 +QR_STATUS_TIMEOUT_SECONDS = 35 +TEXT_CHUNK_LIMIT = 1800 + + +@dataclass +class _QrSession: + """内存中的一次二维码登录会话。""" + + session_id: str + qrcode: str + qr_url: str + created_at: float + poll_base_url: str = DEFAULT_BASE_URL + state: str = "waiting" + + @property + def expired(self) -> bool: + return monotonic() - self.created_at >= QR_SESSION_TTL_SECONDS + + +@dataclass +class _RuntimeCredentials: + """非 Windows 平台的进程内凭据缓存。 + + AUTO-MAS 现有配置的密文实现依赖 Windows DPAPI。微信扫码本身不应因为 + 开发环境运行在 macOS/Linux 而失败,因此这些平台只在当前进程保留凭据, + 不把 Token 降级写入明文配置文件。 + """ + + token: str + account_id: str + user_id: str + base_url: str + + +@dataclass(frozen=True) +class QrStartResult: + """创建二维码后的公开结果。""" + + session_id: str + qr_url: str + + +@dataclass(frozen=True) +class QrCheckResult: + """二维码轮询后的公开结果。""" + + session_id: str + state: str + message: str + connected: bool = False + + +@dataclass(frozen=True) +class WeixinStatus: + """不包含任何凭据的绑定状态。""" + + enabled: bool + connected: bool + state: str + message: str + + +class RemoteHTTPError(RuntimeError): + """远端返回明确 HTTP 状态码的请求错误。""" + + def __init__(self, status_code: int, message: str) -> None: + self.status_code = status_code + super().__init__(message) + + +def split_text(text: str, limit: int = TEXT_CHUNK_LIMIT) -> list[str]: + """将文本按字符上限拆分,并尽量在换行处断开。 + + iLink 的网关对过长文本可能返回业务失败;通知正文不能依赖模型自行分段, + 因此这里在协议适配层做确定性拆分。 + """ + + if limit < 1: + raise ValueError("文本分段长度必须大于 0") + if not text: + return [""] + if len(text) <= limit: + return [text] + + chunks: list[str] = [] + remaining = text + while len(remaining) > limit: + boundary = remaining.rfind("\n", 0, limit + 1) + if boundary <= 0: + boundary = limit + chunks.append(remaining[:boundary].rstrip("\n")) + remaining = remaining[boundary:] + remaining = remaining.lstrip("\n") + if remaining: + chunks.append(remaining) + return chunks + + +def _is_valid_https_url(value: str) -> bool: + """只接受 iLink 返回的 HTTPS 网关地址。""" + + try: + parsed = urlparse(value) + except ValueError: + return False + return parsed.scheme.lower() == "https" and bool(parsed.netloc) + + +def _safe_base_url(value: Any) -> str: + """校验服务端返回的 baseurl,异常时回退官方网关。""" + + candidate = str(value or "").strip().rstrip("/") + return candidate if _is_valid_https_url(candidate) else DEFAULT_BASE_URL + + +def _client_kwargs(timeout: float) -> dict[str, Any]: + """生成统一 HTTP 客户端参数,避免环境代理劫持二维码/凭据请求。""" + + proxy = Config.proxy + kwargs: dict[str, Any] = {"timeout": timeout, "trust_env": False} + if proxy is not None: + kwargs["proxy"] = proxy + return kwargs + + +def _base_info() -> dict[str, str]: + """构造符合上游约定的客户端标识。""" + + version = str(getattr(Config, "VERSION", "unknown")).lstrip("v") + return { + "channel_version": version, + "bot_agent": f"AUTO-MAS/{version}", + } + + +def _headers(token: str | None = None) -> dict[str, str]: + """构造 iLink 公共请求头。""" + + uint32 = secrets.randbits(32) + headers = { + "Content-Type": "application/json", + "AuthorizationType": "ilink_bot_token", + "X-WECHAT-UIN": base64.b64encode(str(uint32).encode("ascii")).decode("ascii"), + "iLink-App-Id": "bot", + "iLink-App-ClientVersion": CLIENT_VERSION, + } + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _response_code(value: Any) -> int | None: + """将网关返回的数字错误码统一为 int。""" + + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _error_code(response: dict[str, Any]) -> int | None: + """兼容 ret/errcode 两种 iLink 错误码字段。""" + + ret = _response_code(response.get("ret")) + if ret not in (None, 0): + return ret + return _response_code(response.get("errcode")) + + +class OpenClawWeixinManager: + """单账号微信 Claw 通道管理器。""" + + def __init__(self) -> None: + self._sessions: dict[str, _QrSession] = {} + self._session_lock = asyncio.Lock() + self._session_generation = 0 + self._config_lock = asyncio.Lock() + self._send_lock = asyncio.Lock() + self._credential_lock = asyncio.Lock() + self._runtime_credentials: _RuntimeCredentials | None = None + self._secret_storage_available: bool | None = None + + async def start(self) -> None: + """初始化管理器;微信通知不保持后台连接,所有请求按需发起。""" + + return + + async def stop(self) -> None: + """清理临时二维码会话;不会请求或等待远端连接。""" + + async with self._session_lock: + self._sessions.clear() + self._session_generation += 1 + + def _enabled(self) -> bool: + try: + return bool(Config.get("Notify", "IfOpenClawWeixin")) + except (AttributeError, RuntimeError): + return False + + def _config_value(self, name: str, default: Any = "") -> Any: + """读取配置;密文能力不可用时按未绑定处理。""" + + try: + return Config.get("Notify", name) + except Exception as exc: + if platform_secret.is_secret_storage_error(exc): + return default + raise + + def _can_persist_secrets(self) -> bool: + """确认是否可以沿用配置层的 DPAPI 密文存储。""" + + if self._secret_storage_available is not None: + return self._secret_storage_available + self._secret_storage_available = platform_secret.supports_secret_storage() + return self._secret_storage_available + + def _credentials(self) -> tuple[str, str, str]: + if self._runtime_credentials is not None: + runtime = self._runtime_credentials + return runtime.token, runtime.user_id, runtime.base_url + + token = str(self._config_value("OpenClawWeixinBotToken") or "").strip() + user_id = str(self._config_value("OpenClawWeixinTargetUserId") or "").strip() + base_url = _safe_base_url(self._config_value("OpenClawWeixinServerAddress")) + return token, user_id, base_url + + def _account_id(self) -> str: + if self._runtime_credentials is not None: + return self._runtime_credentials.account_id + return str(self._config_value("OpenClawWeixinAccountId") or "").strip() + + def status(self) -> WeixinStatus: + """返回绑定状态,不回传 Token、用户 ID 或上下文内容。""" + + token, user_id, _ = self._credentials() + account_id = self._account_id() + enabled = self._enabled() + # 只有能直接发送消息的凭据才算已绑定;仅有 Bot Token/账号 ID 时, + # send() 仍会因为缺少收件人而失败,不能让前端显示为已绑定。 + connected = bool(token and user_id) + if not connected: + state = "disconnected" + message = ( + "微信绑定信息不完整,请重新扫码绑定" + if token or account_id or user_id + else "请扫码绑定微信" + ) + else: + # 主动通知只依赖扫码返回的凭据,不要求额外的会话上下文。 + state = "connected" + message = "微信已绑定,通知可以发送" + return WeixinStatus( + enabled=enabled, + connected=connected, + state=state, + message=message, + ) + + async def start_login(self) -> QrStartResult: + """获取二维码并创建一次短生命周期登录会话。""" + + async with self._session_lock: + # 单账号只保留最后一次二维码,避免重复点击累积可用登录会话。 + self._sessions.clear() + self._session_generation += 1 + generation = self._session_generation + # 每次扫码都要求服务端返回完整凭据;本地残缺 Token 不参与新会话。 + body = {"local_token_list": []} + + # 二维码接口可能等待十几秒;网络请求必须在会话锁外执行, + # 否则关闭二维码、重新绑定和解绑都会被阻塞。 + response = await self._request_json( + "POST", + f"{DEFAULT_BASE_URL}/ilink/bot/get_bot_qrcode?bot_type={DEFAULT_BOT_TYPE}", + body=body, + token=None, + timeout=QR_REQUEST_TIMEOUT_SECONDS, + ) + qrcode = str(response.get("qrcode") or "").strip() + qr_url = str(response.get("qrcode_img_content") or "").strip() + if not qrcode or not qr_url: + raise RuntimeError("微信二维码响应缺少登录信息") + session_id = uuid.uuid4().hex + async with self._session_lock: + if generation != self._session_generation: + raise RuntimeError("微信二维码登录会话已关闭,请重新生成") + self._sessions[session_id] = _QrSession( + session_id=session_id, + qrcode=qrcode, + qr_url=qr_url, + created_at=monotonic(), + ) + return QrStartResult(session_id=session_id, qr_url=qr_url) + + async def check_login( + self, session_id: str, verify_code: str | None = None + ) -> QrCheckResult: + """查询二维码状态,确认后自动保存账号凭据。""" + + async with self._session_lock: + session = self._sessions.get(session_id) + if session is None: + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话不存在,请重新生成", + ) + if session.expired: + self._sessions.pop(session_id, None) + return QrCheckResult( + session_id=session_id, + state="expired", + message="二维码已过期,请重新生成", + ) + generation = self._session_generation + qrcode = session.qrcode + poll_base_url = session.poll_base_url + + params = {"qrcode": qrcode} + if verify_code and verify_code.strip(): + params["verify_code"] = verify_code.strip() + endpoint = f"{poll_base_url}/ilink/bot/get_qrcode_status?{urlencode(params)}" + try: + response = await self._request_json( + "GET", + endpoint, + body=None, + token=None, + timeout=QR_STATUS_TIMEOUT_SECONDS, + ) + except RemoteHTTPError as exc: + if 500 <= exc.status_code < 600: + return QrCheckResult( + session_id=session_id, + state="waiting", + message=str(exc), + ) + return QrCheckResult( + session_id=session_id, + state="error", + message=str(exc), + ) + except RuntimeError as exc: + return QrCheckResult( + session_id=session_id, + state="waiting", + message=str(exc), + ) + + async with self._session_lock: + if ( + generation != self._session_generation + or self._sessions.get(session_id) is not session + ): + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话已关闭,请重新生成", + ) + + state = str(response.get("status") or "").strip().lower() + if state in {"wait", "scaned"}: + current_state = "scanned" if state == "scaned" else "waiting" + async with self._session_lock: + if ( + generation != self._session_generation + or self._sessions.get(session_id) is not session + ): + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话已关闭,请重新生成", + ) + session.state = current_state + return QrCheckResult( + session_id=session_id, + state=current_state, + message=( + "已扫码,正在确认登录" + if current_state == "scanned" + else "请使用微信扫描二维码" + ), + ) + if state == "need_verifycode": + async with self._session_lock: + if ( + generation != self._session_generation + or self._sessions.get(session_id) is not session + ): + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话已关闭,请重新生成", + ) + session.state = "need_verify_code" + return QrCheckResult( + session_id=session_id, + state="need_verify_code", + message="微信要求输入配对码,请填写手机上显示的数字", + ) + if state == "verify_code_blocked": + async with self._session_lock: + if ( + generation == self._session_generation + and self._sessions.get(session_id) is session + ): + self._sessions.pop(session_id, None) + return QrCheckResult( + session_id=session_id, + state="error", + message="配对码错误次数过多,请重新获取二维码", + ) + if state == "scaned_but_redirect": + redirect_host = str(response.get("redirect_host") or "").strip() + async with self._session_lock: + if ( + generation != self._session_generation + or self._sessions.get(session_id) is not session + ): + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话已关闭,请重新生成", + ) + if redirect_host: + session.poll_base_url = _safe_base_url(f"https://{redirect_host}") + session.state = "scanned" + return QrCheckResult( + session_id=session_id, + state="scanned", + message="已扫码,正在切换登录服务并确认", + ) + if state == "expired": + async with self._session_lock: + if ( + generation == self._session_generation + and self._sessions.get(session_id) is session + ): + self._sessions.pop(session_id, None) + return QrCheckResult( + session_id=session_id, + state="expired", + message="二维码已过期,请重新生成", + ) + if state == "binded_redirect": + async with self._session_lock: + if ( + generation != self._session_generation + or self._sessions.get(session_id) is not session + ): + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话已关闭,请重新生成", + ) + self._sessions.pop(session_id, None) + if self.status().connected: + return QrCheckResult( + session_id=session_id, + state="connected", + connected=True, + message="微信已绑定,无需重复登录", + ) + return QrCheckResult( + session_id=session_id, + state="error", + message="微信账号已绑定,但本地没有可用凭据,请重新扫码", + ) + if state == "confirmed": + bot_token = str(response.get("bot_token") or "").strip() + account_id = str(response.get("ilink_bot_id") or "").strip() + user_id = str(response.get("ilink_user_id") or "").strip() + if not bot_token or not account_id or not user_id: + async with self._session_lock: + if ( + generation == self._session_generation + and self._sessions.get(session_id) is session + ): + self._sessions.pop(session_id, None) + return QrCheckResult( + session_id=session_id, + state="error", + message="登录确认响应缺少账号或收件人信息,请重新扫码", + ) + try: + async with self._send_lock, self._credential_lock: + async with self._session_lock: + if ( + generation != self._session_generation + or self._sessions.get(session_id) is not session + ): + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话已关闭,请重新生成", + ) + self._sessions.pop(session_id, None) + await self._save_credentials_locked( + token=bot_token, + account_id=account_id, + user_id=user_id, + base_url=response.get("baseurl"), + ) + except (ValueError, RuntimeError) as exc: + return QrCheckResult( + session_id=session_id, + state="error", + message=f"微信登录凭据处理失败:{exc}", + ) + async with self._session_lock: + if generation != self._session_generation: + return QrCheckResult( + session_id=session_id, + state="error", + message="二维码登录会话已关闭,请重新生成", + ) + return QrCheckResult( + session_id=session_id, + state="connected", + connected=True, + message="微信扫码绑定成功", + ) + + return QrCheckResult( + session_id=session_id, + state="error", + message=f"微信登录返回未知状态: {state or 'empty'}", + ) + + async def unbind(self) -> None: + """解除绑定并清理所有协议层状态。""" + + # 先让二维码请求失效,不等待正在发送的消息或配置写入。 + await self._invalidate_qr_sessions() + async with self._send_lock: + await self._clear_binding_credentials() + + async def _clear_binding_state(self) -> None: + """清理二维码、凭据和配置中的绑定信息。""" + + await self._invalidate_qr_sessions() + await self._clear_binding_credentials() + + async def _invalidate_qr_sessions(self) -> None: + """使正在进行的二维码请求失效,且不等待任何网络操作。""" + + async with self._session_lock: + self._sessions.clear() + self._session_generation += 1 + + async def _clear_binding_credentials(self) -> None: + """清理凭据及其配置;调用方不应持有会话锁。""" + + async with self._credential_lock: + self._runtime_credentials = None + config_values = { + "IfOpenClawWeixin": False, + "OpenClawWeixinAccountId": "", + "OpenClawWeixinTargetUserId": "", + "OpenClawWeixinServerAddress": DEFAULT_BASE_URL, + } + if self._can_persist_secrets(): + config_values.update( + { + "OpenClawWeixinBotToken": "", + } + ) + async with self._config_lock: + await Config.update({"Notify": config_values}) + + async def send(self, title: str, content: str) -> None: + """发送通知正文,必要时自动拆分长文本。""" + + async with self._send_lock: + token, user_id, base_url = self._credentials() + if not token or not user_id: + await self._invalidate_binding( + reason="微信绑定信息不完整", + ) + raise ValueError("微信绑定信息不完整,请重新扫码绑定") + + # 多段消息会附带序号前缀,预留前缀空间,确保最终单条消息仍不超过网关上限。 + chunks = split_text(content, max(1, TEXT_CHUNK_LIMIT - 16)) + for index, chunk in enumerate(chunks, start=1): + if len(chunks) > 1: + chunk = f"[{index}/{len(chunks)}]\n{chunk}" + message = { + "from_user_id": "", + "to_user_id": user_id, + "client_id": f"auto-mas-{uuid.uuid4().hex}", + "message_type": 2, + "message_state": 2, + "item_list": [{"type": 1, "text_item": {"text": chunk}}], + } + result = await self._request_json( + "POST", + f"{base_url}/ilink/bot/sendmessage", + body={"msg": message, "base_info": _base_info()}, + token=token, + timeout=QR_REQUEST_TIMEOUT_SECONDS, + ) + error_code = _error_code(result) + if error_code not in (None, 0): + if error_code in {-2, -14}: + await self._invalidate_binding( + reason="微信登录状态已失效", + ) + raise RuntimeError("微信登录状态已失效,请重新扫码绑定") + errmsg = result.get("errmsg") or "未知错误" + raise RuntimeError( + f"微信通知发送失败(ret={error_code}):{errmsg}" + ) + logger.success(f"微信Claw通知推送成功: {title}") + + async def _save_credentials( + self, + *, + token: str, + account_id: str, + user_id: str, + base_url: Any, + ) -> None: + """将扫码结果保存到内部配置项,不进入公开设置 schema。""" + + async with self._send_lock, self._credential_lock: + await self._save_credentials_locked( + token=token, + account_id=account_id, + user_id=user_id, + base_url=base_url, + ) + + async def _save_credentials_locked( + self, + *, + token: str, + account_id: str, + user_id: str, + base_url: Any, + ) -> None: + """在发送与凭据锁已取得时保存扫码返回的凭据。""" + + if not token.strip() or not account_id.strip() or not user_id.strip(): + raise ValueError("微信登录确认响应缺少账号或收件人信息") + + normalized = _RuntimeCredentials( + token=token.strip(), + account_id=account_id.strip(), + user_id=user_id.strip(), + base_url=_safe_base_url(base_url), + ) + + if not self._can_persist_secrets(): + # 非 Windows 不把 Bot Token 降级写入明文配置; + # 公开的开关、账号和网关地址仍同步保存,方便当前 UI 正常工作。 + self._runtime_credentials = normalized + async with self._config_lock: + await Config.update( + { + "Notify": { + "OpenClawWeixinAccountId": normalized.account_id, + "OpenClawWeixinTargetUserId": normalized.user_id, + "OpenClawWeixinServerAddress": normalized.base_url, + } + } + ) + logger.warning( + "当前平台不支持 Windows DPAPI,微信凭据仅保存在本次运行内;" + "应用重启后需要重新扫码" + ) + return + + async with self._config_lock: + await Config.update( + { + "Notify": { + "OpenClawWeixinBotToken": normalized.token, + "OpenClawWeixinAccountId": normalized.account_id, + "OpenClawWeixinTargetUserId": normalized.user_id, + "OpenClawWeixinServerAddress": normalized.base_url, + } + } + ) + self._runtime_credentials = None + + async def _invalidate_binding(self, *, reason: str) -> None: + """清理已失效的绑定,让状态接口与实际可发送能力一致。""" + + try: + await self._clear_binding_state() + except Exception as exc: + logger.warning(f"{reason},清理本地绑定状态失败: {exc}") + else: + logger.warning(f"{reason},已清理微信绑定状态") + + async def _request_json( + self, + method: str, + url: str, + *, + body: dict[str, Any] | None, + token: str | None, + timeout: float, + ) -> dict[str, Any]: + """发起 iLink 请求并统一解析 HTTP/JSON 错误。""" + + try: + async with httpx.AsyncClient(**_client_kwargs(timeout)) as client: + response = await client.request( + method, + url, + json=body, + headers=_headers(token), + ) + response.raise_for_status() + payload = response.json() + except httpx.HTTPStatusError as exc: + # 不把二维码或会话令牌所在的完整 URL 带回前端或写入日志。 + raise RemoteHTTPError( + exc.response.status_code, + f"微信 iLink HTTP 请求失败(状态码 {exc.response.status_code})", + ) from exc + except httpx.HTTPError as exc: + raise RuntimeError("微信 iLink 网络请求失败") from exc + except ValueError as exc: + raise RuntimeError("微信 iLink 响应不是合法 JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError("微信 iLink 响应格式无效") + return payload + + +openclaw_weixin_manager = OpenClawWeixinManager() diff --git a/app/tools/game_sign_notify.py b/app/tools/game_sign_notify.py index d50ab972b..5d0df310c 100644 --- a/app/tools/game_sign_notify.py +++ b/app/tools/game_sign_notify.py @@ -32,6 +32,8 @@ global_target, target_channel_names, ) +from app.core.ws import Publisher, protocol +from app.models.schema import WSTaskNoticeData from app.utils.logger import get_logger logger = get_logger("游戏签到通知") @@ -228,7 +230,7 @@ def append_task_game_sign_summary(task_info: object, result: str) -> str: async def dispatch_task_report( payload: NotifyPayload, targets: list[NotifyTarget], - task_info: object, + task_info: object | None, *, summary_text: str = "", attempts: int = 1, @@ -244,9 +246,11 @@ async def dispatch_task_report( delivered = set(getattr(task_info, "game_sign_summary_delivered", ())) if not summary_text: - return await dispatch( + result = await dispatch( payload, targets, attempts=attempts, retry_delay=retry_delay ) + await _publish_task_notification_failure(task_info, result) + return result channels = [ channel for target in targets for channel in target_channel_names(target) @@ -278,13 +282,40 @@ async def dispatch_task_report( else: without_summary = DispatchResult() - setattr(task_info, "game_sign_summary_delivered", delivered) - setattr(task_info, "game_sign_summary_pending", with_summary.failed) - return DispatchResult( + if task_info is not None: + setattr(task_info, "game_sign_summary_delivered", delivered) + setattr(task_info, "game_sign_summary_pending", with_summary.failed) + result = DispatchResult( attempted=with_summary.attempted + without_summary.attempted, succeeded=with_summary.succeeded + without_summary.succeeded, failed=with_summary.failed + without_summary.failed, ) + await _publish_task_notification_failure(task_info, result) + return result + + +async def _publish_task_notification_failure( + task_info: object | None, result: DispatchResult +) -> None: + """把任务报告的渠道失败同步提示到当前任务页面。""" + + if not result.failed or task_info is None: + return + task_id = getattr(task_info, "task_id", None) + if not task_id: + return + + failed_channels = tuple(dict.fromkeys(result.failed)) + title = "部分通知发送失败" if result.succeeded else "通知发送失败" + message = f"{title}:{'、'.join(failed_channels)}" + try: + await Publisher.send( + id=str(task_id), + type=protocol.TASK_NOTICE, + data=WSTaskNoticeData(level="warning", message=message), + ) + except Exception as exc: # noqa: BLE001 + logger.warning(f"发送通知失败提示到前端时出现异常: {exc}") async def push_game_sign_notification(results: list[dict]) -> DispatchResult: diff --git a/app/utils/platform/common/secret.py b/app/utils/platform/common/secret.py index 923b96d9b..db9653ad3 100644 --- a/app/utils/platform/common/secret.py +++ b/app/utils/platform/common/secret.py @@ -1,9 +1,27 @@ from app.utils.platform.common.errors import UnsupportedPlatformError -def dpapi_encrypt(*args, **kwargs) -> str: +def supports_secret_storage() -> bool: + """当前平台是否提供配置层所需的密文存储能力。""" + + return False + + +def is_secret_storage_error(error: BaseException) -> bool: + """判断异常是否表示平台不支持密文存储。""" + + return isinstance(error, UnsupportedPlatformError) and getattr( + error, "capability", None + ) == "secret" + + +def dpapi_encrypt(note: str, *args, **kwargs) -> str: + if note == "": + return "" raise UnsupportedPlatformError("secret") -def dpapi_decrypt(*args, **kwargs) -> str: +def dpapi_decrypt(note: str, *args, **kwargs) -> str: + if note == "": + return "" raise UnsupportedPlatformError("secret") diff --git a/app/utils/platform/windows/secret.py b/app/utils/platform/windows/secret.py index 71dac8f9b..ddc33031b 100644 --- a/app/utils/platform/windows/secret.py +++ b/app/utils/platform/windows/secret.py @@ -1,5 +1,8 @@ import base64 +from app.utils.platform.common.errors import UnsupportedPlatformError + +_SECRET_STORAGE_PROBE = "AUTO-MAS secret storage probe" def _win32crypt(): @@ -35,3 +38,21 @@ def dpapi_decrypt(note: str, entropy: None | bytes = None) -> str: base64.b64decode(note), entropy, None, None, 0 ) return decrypted[1].decode("utf-8") + + +def supports_secret_storage() -> bool: + """检测 Windows DPAPI 是否可供当前进程使用。""" + + try: + encrypted = dpapi_encrypt(_SECRET_STORAGE_PROBE) + return bool(encrypted and dpapi_decrypt(encrypted) == _SECRET_STORAGE_PROBE) + except Exception: + return False + + +def is_secret_storage_error(error: BaseException) -> bool: + """判断异常是否表示平台不支持密文存储。""" + + return isinstance(error, UnsupportedPlatformError) and getattr( + error, "capability", None + ) == "secret" diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 545e937e5..9cb848422 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -212,6 +212,14 @@ export type { OkwwUserConfig_Data } from './models/OkwwUserConfig_Data'; export type { OkwwUserConfig_Info } from './models/OkwwUserConfig_Info'; export type { OkwwUserConfig_Notify } from './models/OkwwUserConfig_Notify'; export type { OkwwUserConfig_Task } from './models/OkwwUserConfig_Task'; +export type { OpenClawQQQrCheckIn } from './models/OpenClawQQQrCheckIn'; +export type { OpenClawQQQrCheckOut } from './models/OpenClawQQQrCheckOut'; +export type { OpenClawQQQrStartOut } from './models/OpenClawQQQrStartOut'; +export type { OpenClawQQStatusOut } from './models/OpenClawQQStatusOut'; +export type { OpenClawWeixinQrCheckIn } from './models/OpenClawWeixinQrCheckIn'; +export type { OpenClawWeixinQrCheckOut } from './models/OpenClawWeixinQrCheckOut'; +export type { OpenClawWeixinQrStartOut } from './models/OpenClawWeixinQrStartOut'; +export type { OpenClawWeixinStatusOut } from './models/OpenClawWeixinStatusOut'; export type { OutBase } from './models/OutBase'; export type { PatternDebugIn } from './models/PatternDebugIn'; export type { PatternDebugOut } from './models/PatternDebugOut'; @@ -334,6 +342,7 @@ export { Service } from './services/Service'; export { ActionService } from './services/ActionService'; export { AddService } from './services/AddService'; export { BetterGiService } from './services/BetterGiService'; +export { ClawService } from './services/ClawService'; export { DeleteService } from './services/DeleteService'; export { GameSignService } from './services/GameSignService'; export { GetService } from './services/GetService'; @@ -342,4 +351,5 @@ export { M9AService } from './services/M9AService'; export { MaaFwService } from './services/MaaFwService'; export { OcrService } from './services/OcrService'; export { OknteService } from './services/OknteService'; +export { QqService } from './services/QqService'; export { UpdateService } from './services/UpdateService'; diff --git a/frontend/src/api/models/BackendHealthOut.ts b/frontend/src/api/models/BackendHealthOut.ts index dded02ebf..8ca3153c2 100644 --- a/frontend/src/api/models/BackendHealthOut.ts +++ b/frontend/src/api/models/BackendHealthOut.ts @@ -18,5 +18,17 @@ export type BackendHealthOut = { * 后台初始化失败原因 */ backgroundError?: (string | null); + /** + * 后端自身支持的健康检查协议版本 + */ + protocol: number; + /** + * 后端版本号 + */ + version: string; + /** + * 后端所在提交哈希,未受监督或监督器未注入时为空 + */ + commit: string; }; diff --git a/frontend/src/api/models/GlobalConfig_Notify.ts b/frontend/src/api/models/GlobalConfig_Notify.ts index ed604a169..ff8e798a6 100644 --- a/frontend/src/api/models/GlobalConfig_Notify.ts +++ b/frontend/src/api/models/GlobalConfig_Notify.ts @@ -35,6 +35,14 @@ export type GlobalConfig_Notify = { * Koishi Token */ KoishiToken?: (string | null); + /** + * 是否启用微信 Claw 通知 + */ + IfOpenClawWeixin?: (boolean | null); + /** + * 是否启用 QQ 官方机器人通知 + */ + IfOpenClawQQ?: (boolean | null); /** * SMTP服务器地址 */ diff --git a/frontend/src/api/models/OpenClawQQQrCheckIn.ts b/frontend/src/api/models/OpenClawQQQrCheckIn.ts new file mode 100644 index 000000000..cd696f1e2 --- /dev/null +++ b/frontend/src/api/models/OpenClawQQQrCheckIn.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * QQ 官方机器人二维码状态查询请求。 + */ +export type OpenClawQQQrCheckIn = { + /** + * 二维码登录会话 ID + */ + sessionId: string; +}; + diff --git a/frontend/src/api/models/OpenClawQQQrCheckOut.ts b/frontend/src/api/models/OpenClawQQQrCheckOut.ts new file mode 100644 index 000000000..ac4177c34 --- /dev/null +++ b/frontend/src/api/models/OpenClawQQQrCheckOut.ts @@ -0,0 +1,34 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * QQ 官方机器人二维码状态查询响应。 + */ +export type OpenClawQQQrCheckOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 二维码登录会话 ID + */ + sessionId?: string; + /** + * 二维码状态 + */ + state?: string; + /** + * 是否已完成账号绑定 + */ + connected?: boolean; +}; + diff --git a/frontend/src/api/models/OpenClawQQQrStartOut.ts b/frontend/src/api/models/OpenClawQQQrStartOut.ts new file mode 100644 index 000000000..f636c3860 --- /dev/null +++ b/frontend/src/api/models/OpenClawQQQrStartOut.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * QQ 官方机器人二维码创建响应。 + */ +export type OpenClawQQQrStartOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 二维码登录会话 ID + */ + sessionId?: string; + /** + * 用于生成二维码的登录链接 + */ + qrUrl?: string; +}; + diff --git a/frontend/src/api/models/OpenClawQQStatusOut.ts b/frontend/src/api/models/OpenClawQQStatusOut.ts new file mode 100644 index 000000000..79240e081 --- /dev/null +++ b/frontend/src/api/models/OpenClawQQStatusOut.ts @@ -0,0 +1,34 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * QQ 官方机器人通知绑定状态,不返回任何凭据。 + */ +export type OpenClawQQStatusOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 是否启用 QQ 官方机器人通知 + */ + enabled?: boolean; + /** + * 是否已绑定 QQ 官方机器人 + */ + connected?: boolean; + /** + * 当前连接状态 + */ + state?: string; +}; + diff --git a/frontend/src/api/models/OpenClawWeixinQrCheckIn.ts b/frontend/src/api/models/OpenClawWeixinQrCheckIn.ts new file mode 100644 index 000000000..8e0dc8bbe --- /dev/null +++ b/frontend/src/api/models/OpenClawWeixinQrCheckIn.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 微信 Claw 二维码状态查询请求。 + */ +export type OpenClawWeixinQrCheckIn = { + /** + * 二维码登录会话 ID + */ + sessionId: string; + /** + * 微信要求时输入的配对码 + */ + verifyCode?: (string | null); +}; + diff --git a/frontend/src/api/models/OpenClawWeixinQrCheckOut.ts b/frontend/src/api/models/OpenClawWeixinQrCheckOut.ts new file mode 100644 index 000000000..9736e024f --- /dev/null +++ b/frontend/src/api/models/OpenClawWeixinQrCheckOut.ts @@ -0,0 +1,34 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 微信 Claw 二维码状态查询响应。 + */ +export type OpenClawWeixinQrCheckOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 二维码登录会话 ID + */ + sessionId?: string; + /** + * 二维码状态 + */ + state?: string; + /** + * 是否已完成账号绑定 + */ + connected?: boolean; +}; + diff --git a/frontend/src/api/models/OpenClawWeixinQrStartOut.ts b/frontend/src/api/models/OpenClawWeixinQrStartOut.ts new file mode 100644 index 000000000..2d72166fc --- /dev/null +++ b/frontend/src/api/models/OpenClawWeixinQrStartOut.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 微信 Claw 二维码创建响应。 + */ +export type OpenClawWeixinQrStartOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 二维码登录会话 ID + */ + sessionId?: string; + /** + * 用于生成二维码的登录链接 + */ + qrUrl?: string; +}; + diff --git a/frontend/src/api/models/OpenClawWeixinStatusOut.ts b/frontend/src/api/models/OpenClawWeixinStatusOut.ts new file mode 100644 index 000000000..8687fcfb3 --- /dev/null +++ b/frontend/src/api/models/OpenClawWeixinStatusOut.ts @@ -0,0 +1,34 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 微信 Claw 通知绑定状态,不返回任何凭据。 + */ +export type OpenClawWeixinStatusOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 是否启用微信 Claw 通知 + */ + enabled?: boolean; + /** + * 是否已绑定微信账号 + */ + connected?: boolean; + /** + * 当前连接状态 + */ + state?: string; +}; + diff --git a/frontend/src/api/services/ClawService.ts b/frontend/src/api/services/ClawService.ts new file mode 100644 index 000000000..206b80f84 --- /dev/null +++ b/frontend/src/api/services/ClawService.ts @@ -0,0 +1,70 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { OpenClawWeixinQrCheckIn } from '../models/OpenClawWeixinQrCheckIn'; +import type { OpenClawWeixinQrCheckOut } from '../models/OpenClawWeixinQrCheckOut'; +import type { OpenClawWeixinQrStartOut } from '../models/OpenClawWeixinQrStartOut'; +import type { OpenClawWeixinStatusOut } from '../models/OpenClawWeixinStatusOut'; +import type { OutBase } from '../models/OutBase'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class ClawService { + /** + * 查询微信 Claw 绑定状态 + * 返回微信绑定状态,不返回协议凭据。 + * @returns OpenClawWeixinStatusOut Successful Response + * @throws ApiError + */ + public static getStatusApiSettingOpenclawWeixinStatusPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/setting/openclaw-weixin/status', + }); + } + /** + * 创建微信 Claw 登录二维码 + * 创建二维码;Bot Token 等凭据只在后台登录确认后保存。 + * @returns OpenClawWeixinQrStartOut Successful Response + * @throws ApiError + */ + public static startLoginApiSettingOpenclawWeixinLoginStartPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/setting/openclaw-weixin/login/start', + }); + } + /** + * 查询微信 Claw 登录状态 + * 查询二维码状态;确认后自动保存账号凭据。 + * @param requestBody + * @returns OpenClawWeixinQrCheckOut Successful Response + * @throws ApiError + */ + public static checkLoginApiSettingOpenclawWeixinLoginCheckPost( + requestBody: OpenClawWeixinQrCheckIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/setting/openclaw-weixin/login/check', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 解除微信 Claw 绑定 + * 解除绑定并清理本地保存的微信协议状态。 + * @returns OutBase Successful Response + * @throws ApiError + */ + public static unbindApiSettingOpenclawWeixinUnbindPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/setting/openclaw-weixin/unbind', + }); + } +} diff --git a/frontend/src/api/services/QqService.ts b/frontend/src/api/services/QqService.ts new file mode 100644 index 000000000..49ea21807 --- /dev/null +++ b/frontend/src/api/services/QqService.ts @@ -0,0 +1,70 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { OpenClawQQQrCheckIn } from '../models/OpenClawQQQrCheckIn'; +import type { OpenClawQQQrCheckOut } from '../models/OpenClawQQQrCheckOut'; +import type { OpenClawQQQrStartOut } from '../models/OpenClawQQQrStartOut'; +import type { OpenClawQQStatusOut } from '../models/OpenClawQQStatusOut'; +import type { OutBase } from '../models/OutBase'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class QqService { + /** + * 查询 QQ 官方机器人绑定状态 + * 返回 QQ 绑定状态,不返回协议凭据。 + * @returns OpenClawQQStatusOut Successful Response + * @throws ApiError + */ + public static getStatusApiSettingOpenclawQqStatusPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/setting/openclaw-qq/status', + }); + } + /** + * 创建 QQ 官方机器人登录二维码 + * 创建二维码;App ID 和客户端密钥只在后台登录确认后保存。 + * @returns OpenClawQQQrStartOut Successful Response + * @throws ApiError + */ + public static startLoginApiSettingOpenclawQqLoginStartPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/setting/openclaw-qq/login/start', + }); + } + /** + * 查询 QQ 官方机器人登录状态 + * 轮询二维码状态;确认后自动保存 QQ 机器人凭据。 + * @param requestBody + * @returns OpenClawQQQrCheckOut Successful Response + * @throws ApiError + */ + public static checkLoginApiSettingOpenclawQqLoginCheckPost( + requestBody: OpenClawQQQrCheckIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/setting/openclaw-qq/login/check', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 解除 QQ 官方机器人绑定 + * 解除绑定并清理本地保存的 QQ 协议状态。 + * @returns OutBase Successful Response + * @throws ApiError + */ + public static unbindApiSettingOpenclawQqUnbindPost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/setting/openclaw-qq/unbind', + }); + } +} diff --git a/frontend/src/api/services/Service.ts b/frontend/src/api/services/Service.ts index 98526621d..59f4d7f82 100644 --- a/frontend/src/api/services/Service.ts +++ b/frontend/src/api/services/Service.ts @@ -126,7 +126,10 @@ import { request as __request } from '../core/request'; export class Service { /** * 获取后端就绪状态 - * 返回核心 API 与后台初始化状态。 + * 返回核心 API 与后台初始化状态,供 AUTO-MAS-Runtime 等外部监督器判定就绪与身份。 + * + * version/commit 受监督且监督器注入了期望值时原样回显,否则分别回退到本地版本号 + * 与空字符串;commit 不通过 Git 推断,只能来自监督器注入。 * @returns BackendHealthOut Successful Response * @throws ApiError */ diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 5dea2b3b1..266e663fa 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -2718,6 +2718,53 @@ export default { koishiWsTip: 'Koishi WebSocket server address; ws:// and wss:// are both supported', koishiTokenTip: 'Koishi access token', koishiTokenPlaceholder: 'Enter the Koishi token', + openclawWeixinSection: 'WeChat Claw notifications', + openclawWeixinDoc: 'Open the WeChat Claw guide', + openclawWeixinEnable: 'Enable WeChat notifications', + openclawWeixinTip: 'Receive task notifications through the bound WeChat Claw account', + openclawWeixinSetupHint: + 'Select “Bind with QR code” and scan it with WeChat. Login details are saved automatically.', + openclawWeixinBind: 'Bind with QR code', + openclawWeixinRebind: 'Bind again', + openclawWeixinUnbind: 'Unbind', + openclawWeixinUnbindConfirm: 'Unbinding clears the saved WeChat login on this device. Continue?', + openclawWeixinStatusRetry: 'Refresh binding status', + openclawWeixinBound: 'Bound', + openclawWeixinUnbound: 'Not bound', + openclawWeixinBindSuccess: 'WeChat Claw bound successfully', + openclawWeixinUnbindSuccess: 'WeChat Claw unbound', + openclawWeixinUnbindFailed: 'Failed to unbind WeChat Claw', + openclawWeixinLoginTitle: 'Bind WeChat Claw with QR code', + openclawWeixinQrAlt: 'WeChat Claw login QR code', + openclawWeixinQrLoading: 'Getting a QR code…', + openclawWeixinQrWaiting: 'Scan the QR code with WeChat', + openclawWeixinQrInvalid: 'The QR code response was invalid. Try again later.', + openclawWeixinQrError: 'QR-code login failed. Try again later.', + openclawWeixinQrRetry: 'Get a new QR code', + openclawWeixinVerifyCodePlaceholder: 'Enter the pairing code shown by WeChat', + openclawWeixinVerifyCodeSubmit: 'Confirm pairing code', + openclawQqSection: 'QQ Official Bot notifications', + openclawQqDoc: 'Open the QQ Official Bot guide', + openclawQqEnable: 'Enable QQ notifications', + openclawQqTip: 'Push notifications through the QQ Official Bot', + openclawQqSetupHint: + 'Select “Bind with QR code” and scan it with QQ. Login details are saved automatically.', + openclawQqBind: 'Bind with QR code', + openclawQqRebind: 'Bind again', + openclawQqUnbind: 'Unbind', + openclawQqUnbindConfirm: 'Unbinding clears the saved QQ login on this device. Continue?', + openclawQqStatusRetry: 'Refresh binding status', + openclawQqBound: 'Bound', + openclawQqUnbound: 'Not bound', + openclawQqBindSuccess: 'QQ Official Bot bound successfully', + openclawQqUnbindSuccess: 'QQ Official Bot unbound', + openclawQqLoginTitle: 'Bind QQ Official Bot with QR code', + openclawQqQrAlt: 'QQ Official Bot login QR code', + openclawQqQrLoading: 'Getting a QR code…', + openclawQqQrWaiting: 'Scan the QR code with QQ', + openclawQqQrInvalid: 'The QR code response was invalid. Try again later.', + openclawQqQrError: 'QR-code login failed. Try again later.', + openclawQqQrRetry: 'Get a new QR code', webhookSection: 'Custom webhooks', webhookDoc: 'Open the custom webhook configuration docs', }, diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index 2dd26146e..d3b42410e 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -2753,6 +2753,53 @@ export default { koishiWsTip: 'Koishi の WebSocket サーバーアドレス。ws:// と wss:// のどちらにも対応します', koishiTokenTip: 'Koishi のアクセストークン', koishiTokenPlaceholder: 'Koishi のトークンを入力してください', + openclawWeixinSection: 'WeChat Claw 通知', + openclawWeixinDoc: 'WeChat Claw の使い方を開く', + openclawWeixinEnable: 'WeChat 通知を有効にする', + openclawWeixinTip: '連携した WeChat Claw アカウントでタスク通知を受け取ります', + openclawWeixinSetupHint: + '「QR コードで連携」を選び、WeChat で QR コードをスキャンしてください。ログイン情報は自動的に保存されます。', + openclawWeixinBind: 'QR コードで連携', + openclawWeixinRebind: '再連携', + openclawWeixinUnbind: '連携解除', + openclawWeixinUnbindConfirm: '連携を解除すると、この端末に保存した WeChat ログイン状態が消去されます。続行しますか?', + openclawWeixinStatusRetry: '連携状態を再取得', + openclawWeixinBound: '連携済み', + openclawWeixinUnbound: '未連携', + openclawWeixinBindSuccess: 'WeChat Claw を連携しました', + openclawWeixinUnbindSuccess: 'WeChat Claw の連携を解除しました', + openclawWeixinUnbindFailed: 'WeChat Claw の連携解除に失敗しました', + openclawWeixinLoginTitle: 'QR コードで WeChat Claw と連携', + openclawWeixinQrAlt: 'WeChat Claw ログイン QR コード', + openclawWeixinQrLoading: 'QR コードを取得しています…', + openclawWeixinQrWaiting: 'WeChat で QR コードをスキャンしてください', + openclawWeixinQrInvalid: 'QR コードの応答が無効です。後でもう一度お試しください。', + openclawWeixinQrError: 'QR コードログインに失敗しました。後でもう一度お試しください。', + openclawWeixinQrRetry: 'QR コードを再取得', + openclawWeixinVerifyCodePlaceholder: 'WeChat に表示されたペアリングコードを入力', + openclawWeixinVerifyCodeSubmit: 'ペアリングコードを確認', + openclawQqSection: 'QQ 公式ボット通知', + openclawQqDoc: 'QQ 公式ボットの使い方を開く', + openclawQqEnable: 'QQ 通知を有効にする', + openclawQqTip: 'QQ 公式ボットで通知を送信します', + openclawQqSetupHint: + '「QR コードで連携」を選び、QQ で QR コードをスキャンしてください。ログイン情報は自動的に保存されます。', + openclawQqBind: 'QR コードで連携', + openclawQqRebind: '再連携', + openclawQqUnbind: '連携解除', + openclawQqUnbindConfirm: '連携を解除すると、この端末に保存した QQ ログイン状態が消去されます。続行しますか?', + openclawQqStatusRetry: '連携状態を再取得', + openclawQqBound: '連携済み', + openclawQqUnbound: '未連携', + openclawQqBindSuccess: 'QQ 公式ボットを連携しました', + openclawQqUnbindSuccess: 'QQ 公式ボットの連携を解除しました', + openclawQqLoginTitle: 'QR コードで QQ 公式ボットと連携', + openclawQqQrAlt: 'QQ 公式ボットのログイン QR コード', + openclawQqQrLoading: 'QR コードを取得しています…', + openclawQqQrWaiting: 'QQ で QR コードをスキャンしてください', + openclawQqQrInvalid: 'QR コードの応答が無効です。後でもう一度お試しください。', + openclawQqQrError: 'QR コードログインに失敗しました。後でもう一度お試しください。', + openclawQqQrRetry: 'QR コードを再取得', webhookSection: 'カスタム Webhook', webhookDoc: 'カスタム Webhook の設定ドキュメントを開く', }, diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 166a50efb..70cbf3a57 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2618,6 +2618,53 @@ export default { koishiWsTip: 'Koishi WebSocket 服务器地址,支持 ws:// 或 wss:// 协议', koishiTokenTip: 'Koishi的访问令牌', koishiTokenPlaceholder: '请输入Koishi Token', + openclawWeixinSection: '微信 Claw 通知', + openclawWeixinDoc: '查看微信 Claw 使用说明', + openclawWeixinEnable: '启用微信通知', + openclawWeixinTip: '绑定微信 Claw 后,使用扫码账号接收任务通知', + openclawWeixinSetupHint: + '点击“扫码绑定”,使用微信扫描二维码即可完成接入,其他登录信息由系统自动保存。', + openclawWeixinBind: '扫码绑定', + openclawWeixinRebind: '重新绑定', + openclawWeixinUnbind: '解除绑定', + openclawWeixinUnbindConfirm: '解除绑定会清除本机保存的微信登录状态,确定继续吗?', + openclawWeixinStatusRetry: '重新查询绑定状态', + openclawWeixinBound: '已绑定', + openclawWeixinUnbound: '未绑定', + openclawWeixinBindSuccess: '微信 Claw 绑定成功', + openclawWeixinUnbindSuccess: '微信 Claw 已解除绑定', + openclawWeixinUnbindFailed: '解除微信 Claw 绑定失败', + openclawWeixinLoginTitle: '扫码绑定微信 Claw', + openclawWeixinQrAlt: '微信 Claw 登录二维码', + openclawWeixinQrLoading: '正在获取二维码…', + openclawWeixinQrWaiting: '请使用微信扫描二维码', + openclawWeixinQrInvalid: '二维码响应无效,请稍后重试', + openclawWeixinQrError: '二维码登录失败,请稍后重试', + openclawWeixinQrRetry: '重新获取二维码', + openclawWeixinVerifyCodePlaceholder: '请输入微信显示的配对码', + openclawWeixinVerifyCodeSubmit: '确认配对码', + openclawQqSection: 'QQ 官方机器人通知', + openclawQqDoc: '查看 QQ 官方机器人使用说明', + openclawQqEnable: '启用 QQ 通知', + openclawQqTip: '使用 QQ 官方机器人推送通知', + openclawQqSetupHint: + '点击“扫码绑定”,使用 QQ 扫描二维码即可完成接入,其他登录信息由系统自动保存。', + openclawQqBind: '扫码绑定', + openclawQqRebind: '重新绑定', + openclawQqUnbind: '解除绑定', + openclawQqUnbindConfirm: '解除绑定会清除本机保存的 QQ 登录状态,确定继续吗?', + openclawQqStatusRetry: '重新查询绑定状态', + openclawQqBound: '已绑定', + openclawQqUnbound: '未绑定', + openclawQqBindSuccess: 'QQ 官方机器人绑定成功', + openclawQqUnbindSuccess: 'QQ 官方机器人已解除绑定', + openclawQqLoginTitle: '扫码绑定 QQ 官方机器人', + openclawQqQrAlt: 'QQ 官方机器人登录二维码', + openclawQqQrLoading: '正在获取二维码…', + openclawQqQrWaiting: '请使用 QQ 扫描二维码', + openclawQqQrInvalid: '二维码响应无效,请稍后重试', + openclawQqQrError: '二维码登录失败,请稍后重试', + openclawQqQrRetry: '重新获取二维码', webhookSection: '自定义 Webhook 通知', webhookDoc: '查看自定义Webhook配置文档', }, diff --git a/frontend/src/views/setting/TabNotify.vue b/frontend/src/views/setting/TabNotify.vue index 625ac1eab..ea4c85f8e 100644 --- a/frontend/src/views/setting/TabNotify.vue +++ b/frontend/src/views/setting/TabNotify.vue @@ -2,6 +2,7 @@ import { useI18n } from 'vue-i18n' import { QuestionCircleOutlined } from '@ant-design/icons-vue' import type { GlobalConfig } from '@/api' +import ClawBinding from './components/ClawBinding.vue' import WebhookManager from '@/components/WebhookManager.vue' import { handleExternalLink } from '@/utils/openExternal' @@ -354,6 +355,44 @@ const handleWebhookChange = async () => { +
+
+

{{ t('setting.notify.openclawWeixinSection') }}

+ + {{ t('common.doc') }} + +
+ +
+ +
+
+

{{ t('setting.notify.openclawQqSection') }}

+ + {{ t('common.doc') }} + +
+ +
+

{{ t('setting.notify.webhookSection') }}

diff --git a/frontend/src/views/setting/components/ClawBinding.vue b/frontend/src/views/setting/components/ClawBinding.vue new file mode 100644 index 000000000..ebd103763 --- /dev/null +++ b/frontend/src/views/setting/components/ClawBinding.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/frontend/src/views/setting/useClawBinding.test.ts b/frontend/src/views/setting/useClawBinding.test.ts new file mode 100644 index 000000000..ef04c0e0d --- /dev/null +++ b/frontend/src/views/setting/useClawBinding.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + weixin: { status: vi.fn(), start: vi.fn(), check: vi.fn(), unbind: vi.fn() }, + qq: { status: vi.fn(), start: vi.fn(), check: vi.fn(), unbind: vi.fn() }, +})) +vi.mock('@/api', () => ({ + ClawService: { + getStatusApiSettingOpenclawWeixinStatusPost: mocks.weixin.status, + startLoginApiSettingOpenclawWeixinLoginStartPost: mocks.weixin.start, + checkLoginApiSettingOpenclawWeixinLoginCheckPost: mocks.weixin.check, + unbindApiSettingOpenclawWeixinUnbindPost: mocks.weixin.unbind, + }, + QqService: { + getStatusApiSettingOpenclawQqStatusPost: mocks.qq.status, + startLoginApiSettingOpenclawQqLoginStartPost: mocks.qq.start, + checkLoginApiSettingOpenclawQqLoginCheckPost: mocks.qq.check, + unbindApiSettingOpenclawQqUnbindPost: mocks.qq.unbind, + }, +})) +vi.mock('vue', async original => ({ + ...(await original()), + onMounted: vi.fn(), + onBeforeUnmount: vi.fn(), +})) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +vi.mock('ant-design-vue', () => ({ message: { success: vi.fn(), error: vi.fn() } })) +vi.mock('qrcode', () => ({ default: { toDataURL: async () => 'data:qr' } })) +import { useClawBinding } from './useClawBinding' + +beforeEach(() => { + vi.resetAllMocks() + vi.useFakeTimers() +}) +afterEach(() => vi.useRealTimers()) + +describe.each(['weixin', 'qq'] as const)('%s QR binding', channel => { + it('uses the selected service and stops polling after expiration', async () => { + const api = mocks[channel] + api.start.mockResolvedValue({ code: 200, sessionId: channel, qrUrl: 'qr' }) + api.check + .mockResolvedValueOnce({ code: 200, state: 'waiting' }) + .mockResolvedValueOnce({ code: 200, state: 'expired' }) + const flow = useClawBinding(channel, vi.fn()) + await flow.start() + await vi.advanceTimersByTimeAsync(10000) + expect(api.check).toHaveBeenCalledTimes(2) + expect(api.check).toHaveBeenCalledWith({ sessionId: channel }) + expect(flow.state.value).toBe('expired') + flow.close() + }) + it('ignores an old login response after reopening', async () => { + const api = mocks[channel] + let resolveOld!: (result: object) => void + api.start.mockResolvedValue({ code: 200, sessionId: channel, qrUrl: 'qr' }) + api.check + .mockReturnValueOnce( + new Promise(resolve => { + resolveOld = resolve + }) + ) + .mockResolvedValueOnce({ code: 200, state: 'scanned' }) + const onChange = vi.fn() + const flow = useClawBinding(channel, onChange) + await flow.start() + await flow.start() + resolveOld({ code: 200, connected: true }) + await Promise.resolve() + expect(flow.state.value).toBe('scanned') + expect(onChange).not.toHaveBeenCalled() + flow.close() + }) +}) + +it('submits a nonempty WeChat pairing code and pauses automatic polling', async () => { + mocks.weixin.start.mockResolvedValue({ code: 200, sessionId: 'wx', qrUrl: 'qr' }) + mocks.weixin.check.mockResolvedValue({ code: 200, state: 'need_verify_code' }) + const flow = useClawBinding('weixin', vi.fn()) + await flow.start() + flow.submitCode() + await vi.advanceTimersByTimeAsync(10000) + expect(mocks.weixin.check).toHaveBeenCalledTimes(1) + flow.verifyCode.value = ' 1234 ' + flow.submitCode() + expect(mocks.weixin.check).toHaveBeenLastCalledWith({ sessionId: 'wx', verifyCode: '1234' }) + flow.close() +}) + +it('defaults to enabled only for the first binding, not for a rebind', async () => { + mocks.weixin.start.mockResolvedValue({ code: 200, sessionId: 'wx', qrUrl: 'qr' }) + mocks.weixin.check.mockResolvedValue({ code: 200, state: 'connected', connected: true }) + mocks.weixin.status.mockResolvedValue({ + code: 200, + enabled: true, + connected: true, + state: 'connected', + }) + const onChange = vi.fn().mockResolvedValue(undefined) + const flow = useClawBinding('weixin', onChange) + + await flow.start() + await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)) + expect(onChange).toHaveBeenCalledWith(true) + + await flow.start() + await vi.waitFor(() => expect(mocks.weixin.check).toHaveBeenCalledTimes(2)) + expect(onChange).toHaveBeenCalledTimes(1) + flow.close() +}) diff --git a/frontend/src/views/setting/useClawBinding.ts b/frontend/src/views/setting/useClawBinding.ts new file mode 100644 index 000000000..d3048ef48 --- /dev/null +++ b/frontend/src/views/setting/useClawBinding.ts @@ -0,0 +1,187 @@ +import { onBeforeUnmount, onMounted, ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { message } from 'ant-design-vue' +import QRCode from 'qrcode' +import { ClawService, QqService, type OutBase, type OpenClawWeixinStatusOut } from '@/api' + +const POLL_INTERVAL = 2000 + +const CHANNELS = { + weixin: { + prefix: 'openclawWeixin', + status: ClawService.getStatusApiSettingOpenclawWeixinStatusPost, + start: ClawService.startLoginApiSettingOpenclawWeixinLoginStartPost, + check: ClawService.checkLoginApiSettingOpenclawWeixinLoginCheckPost, + unbind: ClawService.unbindApiSettingOpenclawWeixinUnbindPost, + }, + qq: { + prefix: 'openclawQq', + status: QqService.getStatusApiSettingOpenclawQqStatusPost, + start: QqService.startLoginApiSettingOpenclawQqLoginStartPost, + check: QqService.checkLoginApiSettingOpenclawQqLoginCheckPost, + unbind: QqService.unbindApiSettingOpenclawQqUnbindPost, + }, +} + +export type ClawChannel = keyof typeof CHANNELS + +export function useClawBinding( + channel: ClawChannel, + onBoundChange: (enabled: boolean) => Promise +) { + const api = CHANNELS[channel] + const { t } = useI18n() + const label = (key: string) => t(`setting.notify.${api.prefix}${key}`) + const status = ref(null) + const statusLoading = ref(false) + const statusError = ref('') + const unbinding = ref(false) + const open = ref(false) + const loading = ref(false) + const checking = ref(false) + const qrDataUrl = ref('') + const state = ref('idle') + const hint = ref('') + const verifyCode = ref('') + let sessionId = '' + let runId = 0 + let timer: ReturnType | undefined + let isBound = false + let enableAfterBind = false + + const checkResponse = (result: OutBase) => { + if (result.code !== 200) throw new Error(result.message || label('QrError')) + } + + const loadStatus = async () => { + statusLoading.value = true + statusError.value = '' + try { + const result = await api.status() + checkResponse(result) + status.value = result + isBound = !!result.connected + // 后端发现凭据不完整时同时关闭旧的通知开关,避免继续向失效渠道投递。 + if (result.enabled && !result.connected) { + await onBoundChange(false) + status.value = { ...result, enabled: false } + } + } catch (error) { + // 查询失败时清空旧状态,避免新的错误仍沿用上一次的「已绑定」。 + status.value = null + statusError.value = String(error) + } finally { + statusLoading.value = false + } + } + + const close = () => { + runId++ + clearTimeout(timer) + open.value = false + loading.value = checking.value = false + sessionId = qrDataUrl.value = verifyCode.value = '' + state.value = 'idle' + hint.value = '' + } + + const poll = async (id: number, code?: string) => { + if (id !== runId || checking.value) return + checking.value = true + try { + const result = await api.check({ + sessionId, + ...(code ? { verifyCode: code } : {}), + }) + if (id !== runId) return + checkResponse(result) + state.value = result.connected ? 'connected' : result.state || 'waiting' + hint.value = result.message || label('QrWaiting') + if (state.value === 'connected') { + // 首次绑定默认启用;重新绑定只替换凭据,保留用户原来的开关状态。 + if (enableAfterBind) await onBoundChange(true) + isBound = true + await loadStatus() + } else if (['waiting', 'scanned'].includes(state.value)) { + timer = setTimeout(() => void poll(id), POLL_INTERVAL) + } + } catch (error) { + if (id !== runId) return + state.value = 'error' + hint.value = String(error) + } finally { + if (id === runId) checking.value = false + } + } + + const start = async () => { + isBound ||= status.value?.connected === true + enableAfterBind = !isBound + close() + const id = runId + open.value = loading.value = true + state.value = 'loading' + hint.value = label('QrLoading') + try { + const result = await api.start() + if (id !== runId) return + checkResponse(result) + if (!result.sessionId || !result.qrUrl) throw new Error(label('QrInvalid')) + const dataUrl = await QRCode.toDataURL(result.qrUrl, { width: 240, margin: 2 }) + if (id !== runId) return + sessionId = result.sessionId + qrDataUrl.value = dataUrl + state.value = 'waiting' + hint.value = label('QrWaiting') + void poll(id) + } catch (error) { + if (id !== runId) return + state.value = 'error' + hint.value = String(error) + } finally { + if (id === runId) loading.value = false + } + } + + const submitCode = () => { + if (verifyCode.value.trim()) void poll(runId, verifyCode.value.trim()) + } + + const unbind = async () => { + unbinding.value = true + try { + checkResponse(await api.unbind()) + isBound = false + await onBoundChange(false) + await loadStatus() + message.success(label('UnbindSuccess')) + } catch (error) { + message.error(String(error)) + } finally { + unbinding.value = false + } + } + + onMounted(loadStatus) + onBeforeUnmount(close) + + return { + label, + status, + statusLoading, + statusError, + unbinding, + open, + loading, + checking, + qrDataUrl, + state, + hint, + verifyCode, + loadStatus, + close, + start, + submitCode, + unbind, + } +} diff --git a/main.py b/main.py index 0f2ca6d41..bc106253f 100644 --- a/main.py +++ b/main.py @@ -295,6 +295,13 @@ async def initialize_background_services() -> None: await ArknightWin32Toolkit.init() await MainTimer.start() + # Claw 通知管理器只维护扫码会话和凭据,消息请求按需发起。 + from app.services.openclaw_qq import openclaw_qq_manager + from app.services.openclaw_weixin import openclaw_weixin_manager + + await openclaw_weixin_manager.start() + await openclaw_qq_manager.start() + # 初始化 Koishi 系统客户端(如果已启用) if Config.get("Notify", "IfKoishiSupport"): from app.api.ws_command import execute_ws_command @@ -352,6 +359,11 @@ async def shutdown_services() -> None: await System.cancel_power_task() await MainTimer.stop() + from app.services.openclaw_qq import openclaw_qq_manager + from app.services.openclaw_weixin import openclaw_weixin_manager + + await openclaw_weixin_manager.stop() + await openclaw_qq_manager.stop() await TaskManager.stop_task("ALL") # 任务 final_task 可能在收尾时重新安排电源操作,停止后再次兜底取消。 with suppress(RuntimeError): @@ -385,6 +397,8 @@ async def shutdown_services() -> None: setting_router, update_router, ocr_router, + openclaw_qq_router, + openclaw_weixin_router, qr_login_router, ) @@ -415,6 +429,8 @@ async def shutdown_services() -> None: app.include_router(setting_router) app.include_router(update_router) app.include_router(ocr_router) + app.include_router(openclaw_qq_router) + app.include_router(openclaw_weixin_router) # 可选补丁:米游社扫码登录 if qr_login_router is not None: diff --git a/res/version.json b/res/version.json index 9710e6ab4..19863129e 100644 --- a/res/version.json +++ b/res/version.json @@ -10,7 +10,8 @@ "调度队列 新增循环队列:队列里的每个任务可单独设定固定时间或间隔重复运行,并显示接下来要运行的任务与时间 by [@qiyinxi](https://github.com/qiyinxi)", "MFW 项目更新可自行选择下载源(Mirror 酱 / GitHub)与更新通道(稳定版 / 测试版) by [@qiyinxi](https://github.com/qiyinxi)", "调度中心 选中脚本后可再指定一个用户单独运行,只代理该用户而不跑该脚本下的其他用户 by [@qiyinxi](https://github.com/qiyinxi)", - "调度队列 完成后操作可单独设定延时,关机、休眠等操作会在队列结束后先静默等待设定的时长,再照常弹出 60 秒倒计时 by [@qiyinxi](https://github.com/qiyinxi)" + "调度队列 完成后操作可单独设定延时,关机、休眠等操作会在队列结束后先静默等待设定的时长,再照常弹出 60 秒倒计时 by [@qiyinxi](https://github.com/qiyinxi)", + "通知系统 支持扫码绑定微信 Claw 和 QQ 官方机器人并接收任务通知,绑定与启用状态保持独立,绑定失效或推送失败时会明确提示" ], "程序优化": [ "配置来源 MAA、SRC、MaaEnd 与 OK-NTE 用户页的「简洁/详细」配置模式更名为「脚本/用户」,含义不变,旧配置自动迁移 by [@1w1w11w1](https://github.com/1w1w11w1)", diff --git a/tests/core/test_notify.py b/tests/core/test_notify.py index ffe5927ae..059c7f900 100644 --- a/tests/core/test_notify.py +++ b/tests/core/test_notify.py @@ -1,5 +1,5 @@ import asyncio -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from app.core.notify import DispatchResult, NotifyPayload, NotifyTarget, dispatch from app.tools.game_sign_notify import ( @@ -265,3 +265,34 @@ def test_dispatch_task_report_zero_targets_keeps_summary() -> None: assert task.game_sign_summary_delivered == set() assert task.game_sign_summary_pending == () assert task.game_sign_summary_consumed is False + + +def test_dispatch_task_report_publishes_failure_notice() -> None: + class _FailingMailNotify(_Notify): + async def send_mail(self, **kwargs) -> bool: + self.calls.append("邮件") + return False + + task = _Task() + task.task_id = "task-1" + target = NotifyTarget(name="全局", mail_to="user@example.com") + + with ( + patch("app.core.notify.Notify", _FailingMailNotify()), + patch( + "app.tools.game_sign_notify.Publisher.send", new_callable=AsyncMock + ) as publish, + ): + result = _run( + dispatch_task_report( + NotifyPayload(title="报告", text="正文"), [target], task + ) + ) + + assert result.failed == ("全局邮件",) + publish.assert_awaited_once() + assert publish.await_args.kwargs["id"] == "task-1" + assert publish.await_args.kwargs["type"] == "task.notice" + notice = publish.await_args.kwargs["data"] + assert notice.level == "warning" + assert "全局邮件" in notice.message diff --git a/tests/platform/test_entries.py b/tests/platform/test_entries.py index fad5afed1..57cd5bd33 100644 --- a/tests/platform/test_entries.py +++ b/tests/platform/test_entries.py @@ -3,7 +3,7 @@ import pytest from app.services.platform.power import power -from app.utils.platform import IS_WINDOWS, window +from app.utils.platform import IS_WINDOWS, secret, window from app.utils.platform.common.errors import UnsupportedPlatformError from app.utils.platform.common.process import get_main_window_handle, get_window_handles from app.utils.platform.process import platform_process @@ -14,6 +14,7 @@ def test_common_entries_do_not_load_windows_dependencies() -> None: assert platform_process.creation_flags == 0 assert power.supported_actions == frozenset() + assert secret.supports_secret_storage() is False assert {"win32gui", "win32crypt"}.isdisjoint(sys.modules) diff --git a/tests/services/test_openclaw_qq.py b/tests/services/test_openclaw_qq.py new file mode 100644 index 000000000..f73d396d3 --- /dev/null +++ b/tests/services/test_openclaw_qq.py @@ -0,0 +1,149 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from app.services import openclaw_qq as qq +from app.utils.platform.common.errors import UnsupportedPlatformError + + +class FakeConfig: + proxy = None + + def __init__(self): + self.values = {} + self.fail_secret = False + + async def update(self, data): + if self.fail_secret and "OpenClawQQClientSecret" in data["Notify"]: + raise UnsupportedPlatformError("secret") + self.values.update(data["Notify"]) + + +@pytest.mark.parametrize( + "persistent,fail_secret", [(True, False), (False, False), (True, True)] +) +def test_save_credentials_storage_modes(monkeypatch, persistent, fail_secret): + config = FakeConfig() + config.fail_secret = fail_secret + config.values["IfOpenClawQQ"] = False + monkeypatch.setattr(qq, "Config", config) + manager = qq.OpenClawQQManager() + manager._secret_storage_available = persistent + manager._access_token = "old-token" + asyncio.run( + manager._save_credentials( + app_id="new-app", client_secret="secret", user_openid="user" + ) + ) + assert config.values["IfOpenClawQQ"] is False + assert manager._access_token == "" + if persistent and not fail_secret: + assert config.values["OpenClawQQClientSecret"] == "secret" + assert manager._runtime_credentials is None + else: + assert "OpenClawQQClientSecret" not in config.values + assert manager._credentials() == ("new-app", "secret", "user") + + +def test_rebind_waits_for_inflight_send_and_discards_old_token(monkeypatch): + monkeypatch.setattr(qq, "Config", FakeConfig()) + manager = qq.OpenClawQQManager() + manager._secret_storage_available = False + + async def run(): + async with manager._send_lock: + saving = asyncio.create_task( + manager._save_credentials( + app_id="new", client_secret="secret", user_openid="user" + ) + ) + await asyncio.sleep(0) + assert not saving.done() + manager._access_token = "old-account-token" + await asyncio.wait_for(saving, timeout=1) + assert manager._access_token == "" + assert manager._credentials()[0] == "new" + + asyncio.run(run()) + + +def test_unbind_waits_for_send(monkeypatch): + config = FakeConfig() + monkeypatch.setattr(qq, "Config", config) + manager = qq.OpenClawQQManager() + manager._secret_storage_available = False + manager._runtime_credentials = qq._RuntimeCredentials("app", "secret", "user") + + async def run(): + async with manager._send_lock: + unbinding = asyncio.create_task(manager.unbind()) + await asyncio.sleep(0) + assert not unbinding.done() + await asyncio.wait_for(unbinding, timeout=1) + assert manager._runtime_credentials is None + assert config.values["IfOpenClawQQ"] is False + + asyncio.run(run()) + + +def test_token_cache_reused_until_expiry(): + manager = qq.OpenClawQQManager() + manager._request_json = AsyncMock( + return_value={"access_token": "token", "expires_in": 7200} + ) + + async def run(): + assert await manager._ensure_access_token("app", "secret") == "token" + assert await manager._ensure_access_token("app", "secret") == "token" + assert manager._request_json.await_count == 1 + manager._access_token_expires_at = 0 + await manager._ensure_access_token("app", "secret") + assert manager._request_json.await_count == 2 + + asyncio.run(run()) + + +def test_message_sequence_increments_and_wraps(): + manager = qq.OpenClawQQManager() + manager._msg_seq = qq.MESSAGE_SEQUENCE_MAX - 1 + + assert manager._next_msg_seq() == qq.MESSAGE_SEQUENCE_MAX + assert manager._next_msg_seq() == 1 + + +def test_send_uses_a_new_message_sequence_for_each_notification(): + manager = qq.OpenClawQQManager() + manager._credentials = lambda: ("app", "secret", "user") + manager._send_message_with_token = AsyncMock() + + async def run(): + await manager.send("标题", "第一条") + await manager.send("标题", "第二条") + + asyncio.run(run()) + bodies = [ + call.kwargs["body"] + for call in manager._send_message_with_token.await_args_list + ] + assert [body["msg_seq"] for body in bodies] == [1, 2] + + +@pytest.mark.parametrize( + ("status_code", "state"), + [(503, "waiting"), (404, "error")], +) +def test_qr_http_errors_have_retryable_states(status_code, state): + manager = qq.OpenClawQQManager() + manager._sessions["session"] = qq._QrSession( + task_id="task", + aes_key=b"0" * 32, + created_at=qq.monotonic(), + ) + manager._request_json = AsyncMock( + side_effect=qq.RemoteHTTPError(status_code, f"HTTP {status_code}") + ) + + result = asyncio.run(manager.check_login("session")) + + assert result.state == state diff --git a/tests/services/test_openclaw_weixin.py b/tests/services/test_openclaw_weixin.py new file mode 100644 index 000000000..4e2170c97 --- /dev/null +++ b/tests/services/test_openclaw_weixin.py @@ -0,0 +1,116 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from app.services import openclaw_weixin as weixin + + +class FakeConfig: + proxy = None + VERSION = "v-test" + + def __init__(self, values=None): + self.values = dict(values or {}) + + def get(self, group, name): + assert group == "Notify" + return self.values.get(name, "") + + async def update(self, data): + self.values.update(data["Notify"]) + + +def _session(manager): + manager._sessions["session"] = weixin._QrSession( + session_id="session", + qrcode="qrcode", + qr_url="qr-url", + created_at=weixin.monotonic(), + ) + + +@pytest.mark.parametrize( + ("values", "state"), + [ + ( + { + "OpenClawWeixinBotToken": "token", + "OpenClawWeixinAccountId": "account", + "OpenClawWeixinTargetUserId": "user", + }, + "connected", + ), + ( + { + "OpenClawWeixinBotToken": "token", + "OpenClawWeixinAccountId": "account", + }, + "error", + ), + ], +) +def test_binded_redirect_uses_the_same_complete_credential_definition( + monkeypatch, values, state +): + config = FakeConfig(values) + monkeypatch.setattr(weixin, "Config", config) + manager = weixin.OpenClawWeixinManager() + _session(manager) + manager._request_json = AsyncMock(return_value={"status": "binded_redirect"}) + + result = asyncio.run(manager.check_login("session")) + + assert result.state == state + assert result.connected == (state == "connected") + + +def test_incomplete_token_is_not_sent_to_a_new_qr_session(monkeypatch): + monkeypatch.setattr( + weixin, + "Config", + FakeConfig({"OpenClawWeixinBotToken": "stale-token"}), + ) + manager = weixin.OpenClawWeixinManager() + manager._request_json = AsyncMock( + return_value={"qrcode": "qrcode", "qrcode_img_content": "qr-url"} + ) + + asyncio.run(manager.start_login()) + + assert manager._request_json.await_args.kwargs["body"] == {"local_token_list": []} + + +def test_saving_credentials_does_not_change_notification_enable_state(monkeypatch): + config = FakeConfig({"IfOpenClawWeixin": False}) + monkeypatch.setattr(weixin, "Config", config) + manager = weixin.OpenClawWeixinManager() + manager._secret_storage_available = False + + asyncio.run( + manager._save_credentials( + token="token", + account_id="account", + user_id="user", + base_url=None, + ) + ) + + assert config.values["IfOpenClawWeixin"] is False + + +@pytest.mark.parametrize( + ("status_code", "state"), + [(503, "waiting"), (404, "error")], +) +def test_qr_http_errors_have_retryable_states(monkeypatch, status_code, state): + monkeypatch.setattr(weixin, "Config", FakeConfig()) + manager = weixin.OpenClawWeixinManager() + _session(manager) + manager._request_json = AsyncMock( + side_effect=weixin.RemoteHTTPError(status_code, f"HTTP {status_code}") + ) + + result = asyncio.run(manager.check_login("session")) + + assert result.state == state