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 () => {
+
+
+
+
+
+
+
+
+
+