diff --git a/README.md b/README.md index 9a7ba84..f2aa5a3 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ## 中文 -通过扫码把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信和 QQ 机器人。 +通过扫码或已有应用凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信和 QQ 机器人。 -> GitHub 简介:通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。 +> GitHub 简介:通过扫码或应用凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。 ## 界面 @@ -12,11 +12,11 @@ ## 当前内置渠道 -- 飞书:扫码创建并绑定机器人,使用长连接收发消息; +- 飞书:扫码创建机器人,或使用已有 App ID + App Secret 绑定机器人,使用长连接收发消息; - 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息; -- 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。 -- 企业微信:使用企业微信 App 扫码创建并授权智能机器人,通过官方 WebSocket 长连接收消息,原生显示“正在思考中”、工具执行进度和流式回答。 -- QQ:使用手机 QQ 扫码创建或绑定 QQ 机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。 +- 钉钉:扫码创建机器人,或使用已有 Client ID + Client Secret 绑定机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。 +- 企业微信:使用企业微信 App 扫码创建智能机器人,或使用已有 Bot ID + Secret 绑定机器人,通过官方 WebSocket 长连接收消息,原生显示“正在思考中”、工具执行进度和流式回答。 +- QQ:使用手机 QQ 扫码创建机器人,或使用已有 AppID + AppSecret 绑定机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。 其他 IM 平台可继续按同一渠道适配器结构接入。 @@ -28,11 +28,15 @@ npx -y github:xmanrui/dsh-im install 重启 `dsh web`,然后打开「设置 → 插件 → IM机器人」。安装器会用 `dsh-im` 替换 profile 中直接安装的 `dsh-feishu`、`dsh-weixin` 和 `dsh-dingtalk`,但不删除任何渠道数据;原有渠道凭据和扫码绑定会继续使用。 -钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。 +飞书、QQ、钉钉和企业微信页面都提供两种入口:带二维码图标的蓝色「扫码接入机器人」按钮走平台官方扫码流程,右侧带钥匙图标的白色描边「手动接入」按钮连接已经创建的机器人应用。飞书和 QQ 分别填写 App ID + App Secret、AppID + AppSecret;钉钉填写官方 Client ID + Client Secret;企业微信填写官方 Bot ID + Secret。Secret 只提交给本机 Harness Host,并写入受保护的凭据存储;状态接口和机器人列表不会回传 Secret。 -企业微信接入使用企业微信官方扫码流程,不需要手动填写 Bot ID 或 Secret。请使用已加入企业且具有机器人创建或管理权限的企业微信账号扫码,并在手机端确认创建智能机器人。扫码创建的是企业微信智能机器人,不是让插件直接登录个人微信账号。插件不设置本机二次批准流程,企业微信中的机器人可见范围就是入站访问范围,请只开放给信任的企业成员和群聊。 +钉钉扫码接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。 -QQ 接入使用腾讯 QQBot v2 官方扫码流程,不需要手动填写 AppID 或 AppSecret。默认腾讯授权页会把接入方显示为“第三方机器人”;扫码成功后创建或绑定的是 QQ 开放平台机器人,并不是让插件直接控制个人 QQ 账号。扫码者会自动成为该机器人在 Harness 中的使用者,不增加本机二次批准步骤。 +企业微信扫码接入时,请使用已加入企业且具有机器人创建或管理权限的企业微信账号,并在手机端确认创建智能机器人。扫码创建的是企业微信智能机器人,不是让插件直接登录个人微信账号。无论扫码还是凭据绑定,企业微信中的机器人可见范围就是入站访问范围,请只开放给信任的企业成员和群聊。 + +QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把接入方显示为“第三方机器人”;扫码成功后创建的是 QQ 开放平台机器人,并不是让插件直接控制个人 QQ 账号。扫码绑定只接受扫码者的消息;手动凭据无法识别扫码人,因此使用 QQ 开放平台中的机器人可见范围作为入站访问范围。 + +飞书扫码绑定会把扫码者作为允许使用者;手动凭据同样无法识别扫码人,因此使用飞书应用的可见范围作为入站访问范围。请在飞书开放平台中只向信任的租户、群或成员开放应用。 ## 设计 @@ -40,7 +44,7 @@ QQ 接入使用腾讯 QQBot v2 官方扫码流程,不需要手动填写 AppID - 飞书、微信、钉钉、企业微信和 QQ 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件; - 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信和 QQ,不使用启用/停用开关; - 五个渠道保持独立的 RPC、凭据、连接监督和会话映射; -- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret` 或原始用户标识。 +- 浏览器只获得二维码和脱敏状态;手动输入的 Secret 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret` 或原始用户标识。 ## 本地开发 @@ -56,9 +60,9 @@ node bin/dsh-im.mjs install --source . ## English -Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, and QQ bots. +Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing application credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, and QQ bots. -> GitHub description: Connect IM bots to DeepSeek Harness by scanning a QR code (supports Feishu, WeChat, DingTalk, WeCom, and QQ). +> GitHub description: Connect IM bots to DeepSeek Harness by QR code or application credentials (supports Feishu, WeChat, DingTalk, WeCom, and QQ). ## Interface @@ -66,11 +70,11 @@ Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one se ## Built-in channels -- Feishu: create and bind a bot by scanning a QR code, then send and receive messages over a persistent connection. +- Feishu: create a bot by QR code or bind an existing bot with App ID + App Secret, then send and receive messages over a persistent connection. - WeChat: bind a WeChat bot by scanning a QR code, then send and receive messages through Tencent iLink long polling. -- DingTalk: create and authorize a bot by scanning a QR code, receive messages through DingTalk Stream, and stream Harness replies through AI Cards. -- WeCom: create and authorize an intelligent bot by scanning with the WeCom app, receive messages over the official WebSocket connection, and natively show a thinking state, tool progress, and streaming replies. -- QQ: create or bind a QQ bot by scanning with mobile QQ, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned. +- DingTalk: create a bot by QR code or bind an existing bot with Client ID + Client Secret, receive messages through DingTalk Stream, and stream Harness replies through AI Cards. +- WeCom: create an intelligent bot by QR code or bind an existing bot with Bot ID + Secret, receive messages over the official WebSocket connection, and natively show a thinking state, tool progress, and streaming replies. +- QQ: create a bot by QR code or bind an existing bot with AppID + AppSecret, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned. Other IM platforms can be added through the same channel-adapter structure. @@ -82,11 +86,15 @@ npx -y github:xmanrui/dsh-im install Restart `dsh web`, then open **Settings → Plugins → IM Bot**. The installer replaces directly installed `dsh-feishu`, `dsh-weixin`, and `dsh-dingtalk` entries in the profile with `dsh-im` without deleting channel data. -For DingTalk, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members. +Feishu, QQ, DingTalk, and WeCom each provide two entry points. The blue **QR access** action uses the platform QR flow; the key-marked, outlined **Manual access** action immediately to its right connects an existing bot application. Feishu and QQ use App ID + App Secret and AppID + AppSecret respectively, DingTalk uses Client ID + Client Secret, and WeCom uses Bot ID + Secret. Secrets are sent only to the local Harness Host and stored through its protected credential provider; status responses and bot lists never return them. -WeCom uses the official QR authorization flow and does not require manually entering a Bot ID or Secret. Scan with a WeCom account that belongs to an enterprise and can create or manage bots, then confirm creation of the intelligent bot in the mobile app. This creates a WeCom intelligent bot; it does not sign the plugin into a personal WeChat account. There is no second local sender-approval flow, so restrict the bot's WeCom visibility to trusted enterprise members and group chats. +For DingTalk QR binding, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members. -QQ uses Tencent's official QQBot v2 QR flow and does not require manually entering an AppID or AppSecret. Tencent's default authorization page labels the integration as a third-party bot. Scanning creates or binds a QQ Open Platform bot; it does not give the plugin direct control of a personal QQ account. The scanner becomes the Harness user for that bot without an additional local approval step. +For WeCom QR binding, scan with an account that belongs to an enterprise and can create or manage bots, then confirm creation of the intelligent bot in the mobile app. This creates a WeCom intelligent bot; it does not sign the plugin into a personal WeChat account. For both QR and credential binding, restrict the bot's WeCom visibility to trusted enterprise members and group chats. + +QQ QR binding uses Tencent's official QQBot v2 flow. Tencent's default authorization page labels the integration as a third-party bot. Scanning creates a QQ Open Platform bot; it does not give the plugin direct control of a personal QQ account. QR binding accepts only the scanner's messages. Manual credentials cannot identify a scanner, so the bot's QQ Open Platform visibility becomes its inbound access scope. + +Feishu QR binding records the scanner as an allowed user. Manual credentials cannot identify a scanner, so the Feishu application's visibility becomes its inbound access scope. Restrict the application to trusted tenants, groups, or members. ## Design @@ -94,7 +102,7 @@ QQ uses Tencent's official QQBot v2 QR flow and does not require manually enteri - Maintains the Feishu, WeChat, DingTalk, WeCom, and QQ Host, client, and runtime sources in this repository without external standalone channel plugins. - Uses channel logos for WeChat, Feishu, DingTalk, WeCom, and QQ navigation without enable/disable switches. - Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by channel. -- Sends only QR codes and redacted status data to the browser, never App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, or raw user identifiers. +- Returns only QR codes and redacted status data to the browser. Manually entered secrets travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, or raw user identifiers. ## Local development diff --git a/lib/client.js b/lib/client.js index 302b60f..66f1189 100644 --- a/lib/client.js +++ b/lib/client.js @@ -40,7 +40,7 @@ __export(index_exports, { name: () => name }); module.exports = __toCommonJS(index_exports); -var React8 = __toESM(require("react"), 1); +var React9 = __toESM(require("react"), 1); // plugin-src/client/channel-logos.js var React = __toESM(require("react"), 1); @@ -131,6 +131,7 @@ var DINGTALK_ENDPOINTS = Object.freeze({ beginProvisioning: "provision.begin", pollProvisioning: "provision.poll", cancelProvisioning: "provision.cancel", + bindCredentials: "bot.bind-credentials", reconnectBot: "bot.reconnect", deleteBot: "bot.delete" }); @@ -304,7 +305,145 @@ function formatRemaining(milliseconds) { } // plugin-src/client/channels/dingtalk/index.js +var React3 = __toESM(require("react"), 1); + +// plugin-src/client/credential-binding.js var React2 = __toESM(require("react"), 1); +var h2 = React2.createElement; +function ActionIcon({ children }) { + return h2("svg", { + className: "dim-actionIcon", + width: 15, + height: 15, + viewBox: "0 0 20 20", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": "true", + focusable: "false" + }, children); +} +function QrActionIcon() { + return h2( + ActionIcon, + null, + h2("path", { + d: "M2.5 2.5h5v5h-5v-5Zm10 0h5v5h-5v-5Zm-10 10h5v5h-5v-5Z", + stroke: "currentColor", + strokeWidth: "1.6", + strokeLinejoin: "round" + }), + h2("path", { + d: "M11.5 11.5h2v2h-2v-2Zm4 0h2v3h-2v-3Zm-4 4h3v2h-3v-2Zm5 1h1v1h-1v-1Z", + fill: "currentColor" + }) + ); +} +function CredentialActionIcon() { + return h2( + ActionIcon, + null, + h2("circle", { + cx: "6.25", + cy: "10", + r: "3.5", + stroke: "currentColor", + strokeWidth: "1.6" + }), + h2("path", { + d: "M9.75 10h7.75m-2.5 0v2m-2.5-2v2", + stroke: "currentColor", + strokeWidth: "1.6", + strokeLinecap: "round", + strokeLinejoin: "round" + }) + ); +} +function CredentialBindingPanel({ + channel, + identityLabel, + identityPlaceholder, + secretLabel, + secretPlaceholder, + busy = false, + error = null, + onSubmit, + onCancel +}) { + const [identity, setIdentity] = React2.useState(""); + const [secret, setSecret] = React2.useState(""); + const headingId = React2.useId(); + const submit = (event) => { + event.preventDefault(); + const normalizedIdentity = identity.trim(); + const normalizedSecret = secret.trim(); + if (!normalizedIdentity || !normalizedSecret || busy) return; + void onSubmit?.({ identity: normalizedIdentity, secret: normalizedSecret }); + }; + return h2( + "section", + { + className: "ddt-card dim-surfaceCard dim-credentialPanel", + "aria-labelledby": headingId + }, + h2("h3", { id: headingId, className: "dim-credentialTitle" }, `\u624B\u52A8\u63A5\u5165${channel}\u673A\u5668\u4EBA`), + h2( + "form", + { className: "dim-credentialForm", onSubmit: submit }, + h2( + "label", + { className: "dim-credentialField" }, + h2("span", null, identityLabel), + h2("input", { + value: identity, + onChange: (event) => setIdentity(event.target.value), + placeholder: identityPlaceholder, + maxLength: 512, + autoCapitalize: "none", + autoCorrect: "off", + spellCheck: false, + autoComplete: "off", + disabled: busy, + required: true + }) + ), + h2( + "label", + { className: "dim-credentialField" }, + h2("span", null, secretLabel), + h2("input", { + type: "password", + value: secret, + onChange: (event) => setSecret(event.target.value), + placeholder: secretPlaceholder, + maxLength: 1024, + autoCapitalize: "none", + autoCorrect: "off", + spellCheck: false, + autoComplete: "new-password", + disabled: busy, + required: true + }) + ), + error ? h2("p", { className: "dim-credentialError", role: "alert" }, error.message ?? String(error)) : null, + h2( + "div", + { className: "ddt-actions dim-viewActions dim-credentialActions" }, + h2("button", { + type: "submit", + className: "ddt-button", + "data-kind": "primary", + disabled: busy || !identity.trim() || !secret.trim() + }, busy ? "\u6B63\u5728\u7ED1\u5B9A\u2026" : "\u7ED1\u5B9A\u5E76\u8FDE\u63A5"), + h2("button", { + type: "button", + className: "ddt-button", + onClick: onCancel, + disabled: busy + }, "\u53D6\u6D88") + ) + ) + ); +} // plugin-src/client/channels/dingtalk/styles.js var DINGTALK_STYLE_ID = "xmanrui-dsh-dingtalk-settings"; @@ -387,7 +526,6 @@ var CSS = String.raw` .ddt-errorCode { font: 11px ui-monospace, SFMono-Regular, monospace; opacity: .8; } .ddt-listHeading { display: flex; align-items: center; justify-content: space-between; margin: 2px 0 9px; } .ddt-listHeading h3 { margin: 0; font-size: 14px; } -.ddt-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; } .ddt-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; } .ddt-accountTop { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } .ddt-accountIdentity { min-width: 0; display: flex; align-items: center; gap: 12px; } @@ -450,10 +588,10 @@ function installDingtalkStyles() { } // plugin-src/client/channels/dingtalk/index.js -var h2 = React2.createElement; +var h3 = React3.createElement; var ACTIVE_PROVISION_STATES = /* @__PURE__ */ new Set(["pending", "scanned", "authorizing", "creating", "connecting"]); function DingtalkIcon({ size = 28 }) { - return h2("svg", { + return h3("svg", { width: size, height: size, viewBox: "0 0 48 48", @@ -461,13 +599,13 @@ function DingtalkIcon({ size = 28 }) { xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", focusable: "false" - }, h2("path", { + }, h3("path", { fill: "currentColor", d: "M37.05 22.783c-6.758-5.216-14.378-12.128-22.73-19.538-.655-.585-1.242-.354-1.536.42-1.88 4.973-.058 9.386 2.889 11.932s7.368 4.912 10.058 6.155c.105.049.013.203-.093.163-4.953-2.182-8.397-3.765-13.07-7.368-.497-.388-1.01-.242-1.07.521-.384 4.748 2.657 8.483 6.058 9.745 2.1.781 4.398 1.212 6.53 1.474.109.015.084.178-.027.178-2.747.01-6.058-.654-8.935-1.751-.606-.233-.818.25-.722.633.491 2.008 2.974 5.076 6.926 5.73a12 12 0 0 0 2.228.115c.164 0 .208.089.154.217q-2.685 4.6-2.803 4.797c-.091.152-.036.275.156.275h3.543c.164 0 .264.106.18.246l-4.958 8.196c-.191.328.035.565.395.301s15.212-11.133 15.636-11.448c.195-.142.148-.327-.124-.327h-3.18c-.206 0-.252-.14-.111-.28.14-.141 3.602-3.594 4.837-4.888 1.283-1.35 1.938-3.825-.231-5.498" })); } -var Button = React2.forwardRef(function Button2({ children, kind = "secondary", className = "", ...props }, ref) { - return h2("button", { +var Button = React3.forwardRef(function Button2({ children, kind = "secondary", className = "", ...props }, ref) { + return h3("button", { ...props, ref, type: "button", @@ -475,146 +613,159 @@ var Button = React2.forwardRef(function Button2({ children, kind = "secondary", "data-kind": kind }, children); }); -function Heading({ totals, adding, busy, onAdd, addButtonRef }) { - return h2( +function Heading({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) { + return h3( "div", { className: "ddt-heading" }, - h2( + h3( "div", { className: "ddt-headingCopy" }, - h2("div", { className: "ddt-eyebrow" }, "Channel"), - h2("h2", null, "\u9489\u9489\u673A\u5668\u4EBA"), - h2("p", null, "\u901A\u8FC7\u626B\u7801\u628A\u9489\u9489\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness") + h3("div", { className: "ddt-eyebrow" }, "Channel"), + h3("h2", null, "\u9489\u9489\u673A\u5668\u4EBA"), + h3("p", null, "\u901A\u8FC7\u626B\u7801\u628A\u9489\u9489\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness") ), - h2( + h3( "div", { className: "ddt-tools" }, - h2(Button, { - kind: "primary", - className: "dim-scanButton", - onClick: onAdd, - disabled: adding || busy, - ref: addButtonRef - }, adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u9489\u9489"), - totals.configured > 0 ? h2( + h3( + "div", + { className: "dim-bindActions" }, + h3(Button, { + kind: "primary", + className: "dim-scanButton", + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + "aria-label": "\u626B\u7801\u63A5\u5165\u9489\u9489\u673A\u5668\u4EBA" + }, h3(QrActionIcon), adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), + h3(Button, { + kind: "credential", + className: "dim-credentialButton", + onClick: onCredential, + disabled: adding || busy, + "aria-pressed": credentialOpen, + "aria-label": "\u4F7F\u7528 Client ID \u548C Client Secret \u7ED1\u5B9A\u9489\u9489\u673A\u5668\u4EBA" + }, h3(CredentialActionIcon), credentialOpen ? "\u6536\u8D77\u51ED\u636E" : "\u624B\u52A8\u63A5\u5165") + ), + totals.configured > 0 ? h3( "div", { className: "ddt-badge dim-onlineBadge" }, - h2("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) + h3("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) ) : null ) ); } function LoadingView() { - return h2( + return h3( "div", { className: "ddt-card ddt-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h2("div", { className: "ddt-spinner dim-spinner" }), - h2("span", null, "\u6B63\u5728\u8BFB\u53D6\u9489\u9489\u8FDE\u63A5\u72B6\u6001\u2026") + h3("div", { className: "ddt-spinner dim-spinner" }), + h3("span", null, "\u6B63\u5728\u8BFB\u53D6\u9489\u9489\u8FDE\u63A5\u72B6\u6001\u2026") ); } function EmptyView({ busy, onStart }) { - return h2( + return h3( "div", { className: "ddt-card dim-surfaceCard" }, - h2( + h3( "div", { className: "ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView" }, - h2( + h3( "div", { className: "dim-emptyCopy" }, - h2( + h3( "div", { className: "ddt-stateLabel dim-stateLabel" }, - h2("span", { className: "ddt-dot dim-stateDot" }), - h2("span", null, "\u5C1A\u672A\u63A5\u5165\u9489\u9489\u673A\u5668\u4EBA") + h3("span", { className: "ddt-dot dim-stateDot" }), + h3("span", null, "\u5C1A\u672A\u63A5\u5165\u9489\u9489\u673A\u5668\u4EBA") ), - h2("h3", null, "\u626B\u4E00\u6B21\u7801\uFF0C\u81EA\u52A8\u521B\u5EFA\u5E76\u8FDE\u63A5\u673A\u5668\u4EBA"), - h2("p", null, "\u6388\u6743\u7531\u9489\u9489\u5B98\u65B9\u9875\u9762\u5B8C\u6210\u3002\u626B\u7801\u8D26\u53F7\u5FC5\u987B\u5DF2\u52A0\u5165\u4E00\u4E2A\u4F01\u4E1A/\u7EC4\u7EC7\u5E76\u6709\u6743\u521B\u5EFA\u673A\u5668\u4EBA\uFF1B\u521B\u5EFA\u6210\u529F\u540E\uFF0C\u5E94\u7528\u51ED\u636E\u4F1A\u76F4\u63A5\u5199\u5165 Harness Host\u3002"), - h2( + h3("h3", null, "\u626B\u4E00\u6B21\u7801\uFF0C\u81EA\u52A8\u521B\u5EFA\u5E76\u8FDE\u63A5\u673A\u5668\u4EBA"), + h3("p", null, "\u6388\u6743\u7531\u9489\u9489\u5B98\u65B9\u9875\u9762\u5B8C\u6210\u3002\u626B\u7801\u8D26\u53F7\u5FC5\u987B\u5DF2\u52A0\u5165\u4E00\u4E2A\u4F01\u4E1A/\u7EC4\u7EC7\u5E76\u6709\u6743\u521B\u5EFA\u673A\u5668\u4EBA\uFF1B\u521B\u5EFA\u6210\u529F\u540E\uFF0C\u5E94\u7528\u51ED\u636E\u4F1A\u76F4\u63A5\u5199\u5165 Harness Host\u3002"), + h3( "div", { className: "ddt-actions dim-viewActions" }, - h2( + h3( Button, { kind: "primary", onClick: onStart, disabled: busy }, busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210\u9489\u9489\u4E8C\u7EF4\u7801" ) ) ), - h2( + h3( "div", { className: "ddt-brandMark dim-emptyBrand", "aria-hidden": "true" }, - h2(DingtalkIcon, { size: 68 }) + h3(DingtalkIcon, { size: 68 }) ) ) ); } function QrPanel({ provision, now, busy, onRefresh, onCancel }) { - const [imageFailed, setImageFailed] = React2.useState(false); + const [imageFailed, setImageFailed] = React3.useState(false); const source = safeQrSource(provision.qrCodeDataUrl); const remaining = Math.max(0, provision.expiresAt - now); const expired = remaining === 0 || provision.status === "expired"; const duration = Math.max(1, provision.durationMs ?? 10 * 6e4); const progress = Math.round(Math.min(1, remaining / duration) * 100); - React2.useEffect(() => setImageFailed(false), [source]); - return h2( + React3.useEffect(() => setImageFailed(false), [source]); + return h3( "div", { className: "ddt-card dim-surfaceCard" }, - h2( + h3( "div", { className: "ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout" }, - h2( + h3( "div", { className: "ddt-qrColumn dim-qrColumn" }, - h2( + h3( "div", { className: "ddt-qrFrame dim-qrFrame" }, - source && !imageFailed ? h2("img", { + source && !imageFailed ? h3("img", { src: source, alt: "\u7528\u4E8E\u628A\u9489\u9489\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness \u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801", onError: () => setImageFailed(true) - }) : h2("div", { className: "ddt-qrFallback dim-qrFallback" }, "\u4E8C\u7EF4\u7801\u56FE\u7247\u672A\u5C31\u7EEA\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"), - expired ? h2("div", { className: "ddt-expired dim-qrExpired" }, "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\n\u8BF7\u91CD\u65B0\u751F\u6210") : null + }) : h3("div", { className: "ddt-qrFallback dim-qrFallback" }, "\u4E8C\u7EF4\u7801\u56FE\u7247\u672A\u5C31\u7EEA\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"), + expired ? h3("div", { className: "ddt-expired dim-qrExpired" }, "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\n\u8BF7\u91CD\u65B0\u751F\u6210") : null ), - h2( + h3( "div", { className: "ddt-countdown dim-countdown" }, - h2( + h3( "div", { className: "ddt-countdownTop dim-countdownTop" }, - h2("span", null, "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), - h2("strong", null, formatRemaining(remaining)) + h3("span", null, "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), + h3("strong", null, formatRemaining(remaining)) ), - h2( + h3( "div", { className: "ddt-progress dim-progress", "aria-hidden": "true" }, - h2("span", { style: { "--ddt-progress": `${progress}%` } }) + h3("span", { style: { "--ddt-progress": `${progress}%` } }) ) ) ), - h2( + h3( "div", { className: "ddt-qrCopy dim-qrCopy" }, - h2( + h3( "div", { className: "ddt-stateLabel dim-stateLabel" }, - h2("span", { className: "ddt-dot dim-stateDot", "data-tone": expired ? "error" : "warning" }), - h2("span", null, expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : "\u7B49\u5F85\u9489\u9489\u626B\u7801\u6388\u6743") + h3("span", { className: "ddt-dot dim-stateDot", "data-tone": expired ? "error" : "warning" }), + h3("span", null, expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : "\u7B49\u5F85\u9489\u9489\u626B\u7801\u6388\u6743") ), - h2("h3", null, expired ? "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u540E\u7EE7\u7EED" : "\u4F7F\u7528\u9489\u9489 App \u5B8C\u6210\u673A\u5668\u4EBA\u6388\u6743"), - h2("p", null, "\u626B\u7801\u8D26\u53F7\u5FC5\u987B\u5DF2\u52A0\u5165\u4F01\u4E1A/\u7EC4\u7EC7\u3002\u5982\u679C\u9489\u9489\u63D0\u793A\u5C1A\u672A\u52A0\u5165\u7EC4\u7EC7\uFF0C\u8BF7\u5728\u63D0\u793A\u9875\u521B\u5EFA\u7EC4\u7EC7\uFF0C\u6216\u6362\u7528\u5DF2\u52A0\u5165\u7EC4\u7EC7\u7684\u8D26\u53F7\u3002"), - h2( + h3("h3", null, expired ? "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u540E\u7EE7\u7EED" : "\u4F7F\u7528\u9489\u9489 App \u5B8C\u6210\u673A\u5668\u4EBA\u6388\u6743"), + h3("p", null, "\u626B\u7801\u8D26\u53F7\u5FC5\u987B\u5DF2\u52A0\u5165\u4F01\u4E1A/\u7EC4\u7EC7\u3002\u5982\u679C\u9489\u9489\u63D0\u793A\u5C1A\u672A\u52A0\u5165\u7EC4\u7EC7\uFF0C\u8BF7\u5728\u63D0\u793A\u9875\u521B\u5EFA\u7EC4\u7EC7\uFF0C\u6216\u6362\u7528\u5DF2\u52A0\u5165\u7EC4\u7EC7\u7684\u8D26\u53F7\u3002"), + h3( "ol", { className: "ddt-steps dim-steps" }, - h2("li", null, "\u4F7F\u7528\u5DF2\u52A0\u5165\u4F01\u4E1A/\u7EC4\u7EC7\u7684\u9489\u9489\u8D26\u53F7\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), - h2("li", null, "\u5728\u6388\u6743\u9875\u70B9\u51FB\u201C\u4E00\u952E\u521B\u5EFA\u65B0\u673A\u5668\u4EBA\u201D"), - h2("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u673A\u5668\u4EBA\u81EA\u52A8\u8FDE\u63A5") + h3("li", null, "\u4F7F\u7528\u5DF2\u52A0\u5165\u4F01\u4E1A/\u7EC4\u7EC7\u7684\u9489\u9489\u8D26\u53F7\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), + h3("li", null, "\u5728\u6388\u6743\u9875\u70B9\u51FB\u201C\u4E00\u952E\u521B\u5EFA\u65B0\u673A\u5668\u4EBA\u201D"), + h3("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u673A\u5668\u4EBA\u81EA\u52A8\u8FDE\u63A5") ), - h2( + h3( "div", { className: "ddt-actions dim-viewActions" }, - expired ? h2(Button, { kind: "primary", onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801") : null, - !expired ? h2(Button, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, - h2(Button, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + expired ? h3(Button, { kind: "primary", onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801") : null, + !expired ? h3(Button, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, + h3(Button, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") ) ) ) @@ -623,16 +774,16 @@ function QrPanel({ provision, now, busy, onRefresh, onCancel }) { function ProgressPanel({ status, busy, onCancel }) { const connecting = status === "connecting"; const creating = status === "creating"; - return h2( + return h3( "div", { className: "ddt-card ddt-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h2("div", { className: "ddt-spinner dim-spinner" }), - h2("h3", null, connecting ? "\u673A\u5668\u4EBA\u5DF2\u521B\u5EFA\uFF0C\u6B63\u5728\u5EFA\u7ACB\u6D88\u606F\u8FDE\u63A5" : creating ? "\u6388\u6743\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u521B\u5EFA\u9489\u9489\u673A\u5668\u4EBA" : "\u6B63\u5728\u786E\u8BA4\u9489\u9489\u6388\u6743"), - h2("p", null, connecting ? "\u6B63\u5728\u68C0\u67E5\u9489\u9489 Stream \u957F\u8FDE\u63A5\uFF0C\u6210\u529F\u540E\u4F1A\u81EA\u52A8\u663E\u793A\u4E3A\u5728\u7EBF\u3002" : "\u8BF7\u52FF\u5173\u95ED\u672C\u9875\uFF0C\u9489\u9489\u5B8C\u6210\u6388\u6743\u540E\u5C06\u81EA\u52A8\u7EE7\u7EED\u3002"), - h2( + h3("div", { className: "ddt-spinner dim-spinner" }), + h3("h3", null, connecting ? "\u673A\u5668\u4EBA\u5DF2\u521B\u5EFA\uFF0C\u6B63\u5728\u5EFA\u7ACB\u6D88\u606F\u8FDE\u63A5" : creating ? "\u6388\u6743\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u521B\u5EFA\u9489\u9489\u673A\u5668\u4EBA" : "\u6B63\u5728\u786E\u8BA4\u9489\u9489\u6388\u6743"), + h3("p", null, connecting ? "\u6B63\u5728\u68C0\u67E5\u9489\u9489 Stream \u957F\u8FDE\u63A5\uFF0C\u6210\u529F\u540E\u4F1A\u81EA\u52A8\u663E\u793A\u4E3A\u5728\u7EBF\u3002" : "\u8BF7\u52FF\u5173\u95ED\u672C\u9875\uFF0C\u9489\u9489\u5B8C\u6210\u6388\u6743\u540E\u5C06\u81EA\u52A8\u7EE7\u7EED\u3002"), + h3( "div", { className: "ddt-actions dim-viewActions", style: { justifyContent: "center", marginTop: 14 } }, - h2(Button, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u63A5\u5165") + h3(Button, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u63A5\u5165") ) ); } @@ -641,20 +792,20 @@ function ProvisionError({ provision, busy, onRetry, onClose }) { code: "DINGTALK_PROVISION_FAILED", message: "\u9489\u9489\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210" }; - return h2( + return h3( "div", { className: "ddt-card dim-surfaceCard" }, - h2( + h3( "div", { className: "ddt-inlineError dim-inlineError", role: "alert" }, - h2("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : "\u9489\u9489\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210"), - h2("p", null, error.message), - h2("span", { className: "ddt-errorCode" }, error.code), - h2( + h3("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : "\u9489\u9489\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210"), + h3("p", null, error.message), + h3("span", { className: "ddt-errorCode" }, error.code), + h3( "div", { className: "ddt-actions dim-viewActions" }, - h2(Button, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), - h2(Button, { onClick: onClose, disabled: busy }, "\u5173\u95ED") + h3(Button, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h3(Button, { onClick: onClose, disabled: busy }, "\u5173\u95ED") ) ) ); @@ -672,9 +823,9 @@ function checkedTime(value) { } } function RemoveConfirmation({ account, busy, onConfirm, onCancel }) { - const cancelRef = React2.useRef(null); - React2.useEffect(() => cancelRef.current?.focus(), []); - return h2( + const cancelRef = React3.useRef(null); + React3.useEffect(() => cancelRef.current?.focus(), []); + return h3( "div", { className: "ddt-confirm dim-confirm", @@ -684,13 +835,13 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) { if (event.key === "Escape" && !busy) onCancel(); } }, - h2("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), - h2("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684\u5E94\u7528\u51ED\u636E\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002\u9489\u9489\u5F00\u653E\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), - h2( + h3("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), + h3("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684\u5E94\u7528\u51ED\u636E\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002\u9489\u9489\u5F00\u653E\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), + h3( "div", { className: "ddt-actions dim-viewActions" }, - h2(Button, { ref: cancelRef, onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), - h2( + h3(Button, { ref: cancelRef, onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), + h3( Button, { kind: "danger", onClick: onConfirm, disabled: busy }, busy ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664\u63A5\u5165" @@ -710,62 +861,63 @@ function AccountCard({ const state = busy === "reconnect" ? "connecting" : account.state; const tone = account.connected ? "success" : state === "error" ? "error" : "warning"; const stateLabel = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA"; - return h2( + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); + return h3( "article", { className: "ddt-card dim-botCard", tabIndex: -1, "data-bot-id": account.botId }, - h2( + h3( "div", { className: "ddt-cardBody dim-botCardBody" }, - h2( + h3( "div", { className: "ddt-accountTop dim-botCardTop" }, - h2( + h3( "div", { className: "ddt-accountIdentity dim-botIdentity" }, - h2("div", { className: "ddt-avatar dim-botAvatar", "aria-hidden": "true" }, h2(DingtalkIcon, { size: 29 })), - h2( + h3("div", { className: "ddt-avatar dim-botAvatar", "aria-hidden": "true" }, h3(DingtalkIcon, { size: 29 })), + h3( "div", { className: "dim-botName" }, - h2("h3", { title: account.bot.name }, account.bot.name), - h2("p", { title: account.bot.clientIdMasked }, account.bot.clientIdMasked) + h3("h3", { title: account.bot.name }, account.bot.name), + h3("p", { title: account.bot.clientIdMasked }, account.bot.clientIdMasked) ) ), - h2( + h3( "div", { className: "ddt-health dim-botHealth" }, - h2("span", { className: "ddt-dot dim-healthDot", "data-tone": tone }), - h2("span", null, stateLabel) + h3("span", { className: "ddt-dot dim-healthDot", "data-tone": tone }), + h3("span", null, stateLabel) ) ), - h2( + h3( "dl", { className: "ddt-metrics dim-botMetrics" }, - h2( + h3( "div", { className: "ddt-metric dim-botMetric" }, - h2("dt", null, "\u6D88\u606F\u901A\u9053"), - h2("dd", null, account.connected ? "Stream \u957F\u8FDE\u63A5" : "\u79BB\u7EBF") + h3("dt", null, "\u6D88\u606F\u901A\u9053"), + h3("dd", null, account.connected ? "Stream \u957F\u8FDE\u63A5" : "\u79BB\u7EBF") ), - h2( + h3( "div", { className: "ddt-metric dim-botMetric" }, - h2("dt", null, "\u6700\u8FD1\u68C0\u67E5"), - h2("dd", null, checkedTime(account.health.lastCheckedAt)) + h3("dt", null, "\u6700\u8FD1\u68C0\u67E5"), + h3("dd", null, checkedTime(account.health.lastCheckedAt)) ) ), - h2( + h3( "div", { className: "ddt-accountFooter dim-cardFooter" }, - h2("div", { className: "ddt-summary dim-cardSummary" }, account.error?.message ?? account.health.summary), - h2( + summary ? h3("div", { className: "ddt-summary dim-cardSummary" }, summary) : null, + h3( "div", { className: "ddt-actions dim-cardActions" }, - h2( + h3( Button, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5" ), - h2( + h3( Button, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165" @@ -773,7 +925,7 @@ function AccountCard({ ) ) ), - removing ? h2(RemoveConfirmation, { + removing ? h3(RemoveConfirmation, { account, busy: busy === "delete", onConfirm: onConfirmRemove, @@ -782,19 +934,18 @@ function AccountCard({ ); } function AccountList(props) { - return h2( + return h3( "section", { className: "dim-listSection" }, - h2( + h3( "div", { className: "ddt-listHeading dim-listHeading" }, - h2("h3", null, "\u5DF2\u63A5\u5165\u7684\u9489\u9489\u673A\u5668\u4EBA"), - h2("span", null, `${props.bots.length} \u4E2A`) + h3("h3", null, "\u5DF2\u63A5\u5165\u7684\u9489\u9489\u673A\u5668\u4EBA") ), - h2("ul", { className: "ddt-list dim-botList" }, props.bots.map((account) => h2( + h3("ul", { className: "ddt-list dim-botList" }, props.bots.map((account) => h3( "li", { key: account.botId }, - h2(AccountCard, { + h3(AccountCard, { account, busy: props.busyByBot[account.botId], removing: props.removeTarget === account.botId, @@ -808,25 +959,27 @@ function AccountList(props) { } var EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 }); function DingtalkSettingsTab({ rpcCall }) { - const [model, setModel] = React2.useState({ + const [model, setModel] = React3.useState({ phase: "loading", bots: [], totals: EMPTY_TOTALS, revision: 0, error: null }); - const [provision, setProvision] = React2.useState(null); - const [busy, setBusy] = React2.useState(false); - const [busyByBot, setBusyByBot] = React2.useState({}); - const [removeTarget, setRemoveTarget] = React2.useState(null); - const [notice, setNotice] = React2.useState(""); - const [now, setNow] = React2.useState(() => Date.now()); - const addButtonRef = React2.useRef(null); - const mountedRef = React2.useRef(true); - const statusRequestRef = React2.useRef(0); - const noticeFrameRef = React2.useRef(null); - const focusFrameRef = React2.useRef(null); - React2.useEffect(() => { + const [provision, setProvision] = React3.useState(null); + const [busy, setBusy] = React3.useState(false); + const [busyByBot, setBusyByBot] = React3.useState({}); + const [removeTarget, setRemoveTarget] = React3.useState(null); + const [credentialOpen, setCredentialOpen] = React3.useState(false); + const [credentialError, setCredentialError] = React3.useState(null); + const [notice, setNotice] = React3.useState(""); + const [now, setNow] = React3.useState(() => Date.now()); + const addButtonRef = React3.useRef(null); + const mountedRef = React3.useRef(true); + const statusRequestRef = React3.useRef(0); + const noticeFrameRef = React3.useRef(null); + const focusFrameRef = React3.useRef(null); + React3.useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; @@ -841,8 +994,8 @@ function DingtalkSettingsTab({ rpcCall }) { } }; }, []); - React2.useEffect(() => installDingtalkStyles(), []); - const announce = React2.useCallback((message) => { + React3.useEffect(() => installDingtalkStyles(), []); + const announce = React3.useCallback((message) => { if (!mountedRef.current) return; if (noticeFrameRef.current !== null) { window.cancelAnimationFrame(noticeFrameRef.current); @@ -856,7 +1009,7 @@ function DingtalkSettingsTab({ rpcCall }) { }); } }, []); - const focusAddButton = React2.useCallback(() => { + const focusAddButton = React3.useCallback(() => { if (!mountedRef.current) return; if (focusFrameRef.current !== null) window.cancelAnimationFrame(focusFrameRef.current); focusFrameRef.current = window.requestAnimationFrame(() => { @@ -864,11 +1017,11 @@ function DingtalkSettingsTab({ rpcCall }) { if (mountedRef.current) addButtonRef.current?.focus(); }); }, []); - const invoke = React2.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React3.useCallback(async (endpoint, payload = {}, signal) => { if (typeof rpcCall !== "function") throw new TypeError("\u9489\u9489\u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); return unwrapRpcResult(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React2.useCallback(async ({ + const loadStatus = React3.useCallback(async ({ signal, silent = false, restoreProvisioning = false @@ -908,12 +1061,12 @@ function DingtalkSettingsTab({ rpcCall }) { return void 0; } }, [invoke]); - React2.useEffect(() => { + React3.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); - React2.useEffect(() => { + React3.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); let running = false; @@ -932,15 +1085,17 @@ function DingtalkSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React2.useEffect(() => { + React3.useEffect(() => { if (!provision || !ACTIVE_PROVISION_STATES.has(provision.status)) return void 0; const timer = window.setInterval(() => { if (mountedRef.current) setNow(Date.now()); }, 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); - const startProvisioning = React2.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React3.useCallback(async ({ replace = false } = {}) => { if (!mountedRef.current) return; + setCredentialOpen(false); + setCredentialError(null); setBusy(true); try { if (replace && provision?.attemptId) { @@ -975,7 +1130,32 @@ function DingtalkSettingsTab({ rpcCall }) { if (mountedRef.current) setBusy(false); } }, [announce, invoke, provision?.attemptId]); - const cancelProvisioning = React2.useCallback(async () => { + const bindCredentials = React3.useCallback(async ({ identity, secret }) => { + if (!mountedRef.current) return; + setBusy(true); + setCredentialError(null); + try { + const snapshot = normalizeSnapshot(await invoke( + DINGTALK_ENDPOINTS.bindCredentials, + { clientId: identity, clientSecret: secret } + )); + if (!mountedRef.current) return; + setModel({ + phase: "ready", + bots: snapshot.bots, + totals: snapshot.totals, + revision: snapshot.revision, + error: null + }); + setCredentialOpen(false); + announce("\u9489\u9489\u673A\u5668\u4EBA\u51ED\u636E\u5DF2\u7ED1\u5B9A\u3002"); + } catch (error) { + if (mountedRef.current) setCredentialError(presentError(error)); + } finally { + if (mountedRef.current) setBusy(false); + } + }, [announce, invoke]); + const cancelProvisioning = React3.useCallback(async () => { if (!mountedRef.current) return; setBusy(true); try { @@ -993,7 +1173,7 @@ function DingtalkSettingsTab({ rpcCall }) { if (mountedRef.current) setBusy(false); } }, [announce, focusAddButton, invoke, provision?.attemptId, provision?.status]); - React2.useEffect(() => { + React3.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !ACTIVE_PROVISION_STATES.has(provision.status)) return void 0; const controller = new AbortController(); @@ -1052,7 +1232,7 @@ function DingtalkSettingsTab({ rpcCall }) { timer = null; }; }, [announce, invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); - const setBotBusy = React2.useCallback((botId, operation) => { + const setBotBusy = React3.useCallback((botId, operation) => { if (!mountedRef.current) return; setBusyByBot((current) => { const next = { ...current }; @@ -1061,7 +1241,7 @@ function DingtalkSettingsTab({ rpcCall }) { return next; }); }, []); - const runBotAction = React2.useCallback(async ({ account, operation, endpoint, payload, success }) => { + const runBotAction = React3.useCallback(async ({ account, operation, endpoint, payload, success }) => { if (!mountedRef.current) return void 0; setBotBusy(account.botId, operation); try { @@ -1079,14 +1259,14 @@ function DingtalkSettingsTab({ rpcCall }) { if (mountedRef.current) setBotBusy(account.botId, null); } }, [announce, invoke, loadStatus, setBotBusy]); - const reconnect = React2.useCallback((account) => runBotAction({ + const reconnect = React3.useCallback((account) => runBotAction({ account, operation: "reconnect", endpoint: DINGTALK_ENDPOINTS.reconnectBot, payload: { botId: account.botId }, success: (snapshot) => snapshot?.bots.find((bot) => bot.botId === account.botId)?.connected ? "\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002" : "\u9489\u9489\u4ECD\u672A\u8FDE\u63A5\uFF0C\u63D2\u4EF6\u4F1A\u7EE7\u7EED\u81EA\u52A8\u91CD\u8BD5\u3002" }), [runBotAction]); - const remove = React2.useCallback(async (account) => { + const remove = React3.useCallback(async (account) => { const snapshot = await runBotAction({ account, operation: "delete", @@ -1098,14 +1278,14 @@ function DingtalkSettingsTab({ rpcCall }) { }, [runBotAction]); let provisionView = null; if (provision?.status === "starting") { - provisionView = h2( + provisionView = h3( "div", { className: "ddt-card ddt-loading", "aria-busy": "true" }, - h2("div", { className: "ddt-spinner" }), - h2("span", null, "\u6B63\u5728\u7533\u8BF7\u9489\u9489\u6388\u6743\u4E8C\u7EF4\u7801\u2026") + h3("div", { className: "ddt-spinner" }), + h3("span", null, "\u6B63\u5728\u7533\u8BF7\u9489\u9489\u6388\u6743\u4E8C\u7EF4\u7801\u2026") ); } else if (provision?.status === "pending") { - provisionView = h2(QrPanel, { + provisionView = h3(QrPanel, { provision, now, busy, @@ -1113,47 +1293,67 @@ function DingtalkSettingsTab({ rpcCall }) { onCancel: () => void cancelProvisioning() }); } else if (["scanned", "authorizing", "creating", "connecting"].includes(provision?.status)) { - provisionView = h2(ProgressPanel, { + provisionView = h3(ProgressPanel, { status: provision.status, busy, onCancel: () => void cancelProvisioning() }); } else if (provision && ["failed", "expired", "cancelled"].includes(provision.status)) { - provisionView = h2(ProvisionError, { + provisionView = h3(ProvisionError, { provision, busy, onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }), onClose: () => void cancelProvisioning() }); } - return h2( + const credentialView = credentialOpen ? h3(CredentialBindingPanel, { + channel: "\u9489\u9489", + identityLabel: "Client ID", + identityPlaceholder: "\u586B\u5199\u9489\u9489\u5E94\u7528 Client ID", + secretLabel: "Client Secret", + secretPlaceholder: "\u586B\u5199\u9489\u9489\u5E94\u7528 Client Secret", + busy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { + setCredentialOpen(false); + setCredentialError(null); + } + }) : null; + return h3( "section", { className: "ddt-page dim-channelPage", "aria-label": "\u9489\u9489\u8BBE\u7F6E" }, - h2(Heading, { + h3(Heading, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), + onCredential: () => { + setCredentialOpen((value) => !value); + setCredentialError(null); + }, + credentialOpen, addButtonRef }), - h2("div", { className: "ddt-visuallyHidden", role: "status", "aria-live": "polite" }, notice), - model.error && model.phase === "ready" ? h2("div", { className: "ddt-statusNotice dim-statusNotice", role: "alert" }, `\u72B6\u6001\u5237\u65B0\u5931\u8D25\uFF1A${model.error.message}`) : null, - model.phase === "loading" ? h2(LoadingView) : model.phase === "error" ? h2( + h3("div", { className: "ddt-visuallyHidden", role: "status", "aria-live": "polite" }, notice), + model.error && model.phase === "ready" ? h3("div", { className: "ddt-statusNotice dim-statusNotice", role: "alert" }, `\u72B6\u6001\u5237\u65B0\u5931\u8D25\uFF1A${model.error.message}`) : null, + model.phase === "loading" ? h3(LoadingView) : model.phase === "error" ? h3( "div", { className: "ddt-card dim-surfaceCard" }, - h2( + h3( "div", { className: "ddt-inlineError dim-inlineError", role: "alert" }, - h2("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u9489\u9489\u673A\u5668\u4EBA\u72B6\u6001"), - h2("p", null, model.error?.message ?? "\u8BF7\u7A0D\u540E\u91CD\u8BD5"), - h2(Button, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") + h3("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u9489\u9489\u673A\u5668\u4EBA\u72B6\u6001"), + h3("p", null, model.error?.message ?? "\u8BF7\u7A0D\u540E\u91CD\u8BD5"), + h3(Button, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") ) - ) : h2( - React2.Fragment, + ) : h3( + React3.Fragment, null, + credentialView, provisionView, - model.bots.length === 0 && !provision ? h2(EmptyView, { busy, onStart: () => void startProvisioning() }) : null, - model.bots.length > 0 ? h2(AccountList, { + model.bots.length === 0 && !provision && !credentialOpen ? h3(EmptyView, { busy, onStart: () => void startProvisioning() }) : null, + model.bots.length > 0 ? h3(AccountList, { bots: model.bots, busyByBot, removeTarget, @@ -1167,7 +1367,7 @@ function DingtalkSettingsTab({ rpcCall }) { } // plugin-src/client/channels/feishu/index.js -var React4 = __toESM(require("react"), 1); +var React5 = __toESM(require("react"), 1); // plugin-src/client/channels/feishu/api.js var FEISHU_RPC_CHANNEL = "/feishu"; @@ -1176,6 +1376,7 @@ var FEISHU_ENDPOINTS = Object.freeze({ beginProvisioning: "provision.begin", pollProvisioning: "provision.poll", cancelProvisioning: "provision.cancel", + bindCredentials: "bot.bind-credentials", reconnectBot: "bot.reconnect", disconnectBot: "bot.disconnect", deleteBot: "bot.delete", @@ -1384,7 +1585,7 @@ function formatRemaining2(milliseconds) { } // plugin-src/client/lifecycle.js -var React3 = __toESM(require("react"), 1); +var React4 = __toESM(require("react"), 1); function createPollScheduler({ setTimeoutFn, clearTimeoutFn }) { let disposed = false; let timer; @@ -1446,8 +1647,8 @@ function createAnimationFrameScheduler({ requestFrame, cancelFrame }) { }; } function useAnimationFrameScheduler() { - const schedulerRef = React3.useRef(null); - React3.useEffect(() => { + const schedulerRef = React4.useRef(null); + React4.useEffect(() => { const scheduler = createAnimationFrameScheduler({ requestFrame: (callback) => window.requestAnimationFrame(callback), cancelFrame: (frame) => window.cancelAnimationFrame(frame) @@ -1458,7 +1659,7 @@ function useAnimationFrameScheduler() { if (schedulerRef.current === scheduler) schedulerRef.current = null; }; }, []); - return React3.useCallback( + return React4.useCallback( (callback, key) => schedulerRef.current?.schedule(callback, key) ?? false, [] ); @@ -1852,7 +2053,6 @@ var CSS2 = String.raw` .bxf-listSection { display: flex; flex-direction: column; gap: 10px; } .bxf-listHeading { min-height: 28px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 2px; } .bxf-listHeading h3 { font-size: 14px; line-height: 22px; font-weight: 650; margin: 0; } -.bxf-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; } .bxf-botList { display: flex; flex-direction: column; gap: 12px; margin: 0; padding: 0; list-style: none; } .bxf-botList > li { min-width: 0; } .bxf-botCard:focus { outline: none; } @@ -1970,9 +2170,9 @@ function installFeishuStyles() { } // plugin-src/client/channels/feishu/index.js -var h3 = React4.createElement; +var h4 = React5.createElement; function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) { - return h3("svg", { + return h4("svg", { width: size, height: size, viewBox, @@ -1984,10 +2184,10 @@ function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) { }, children); } function RobotIcon({ size = 26 }) { - return h3( + return h4( SvgIcon, { size }, - h3("rect", { + h4("rect", { x: "5", y: "7.5", width: "14", @@ -1996,7 +2196,7 @@ function RobotIcon({ size = 26 }) { stroke: "currentColor", strokeWidth: "1.7" }), - h3("path", { + h4("path", { d: "M12 4.5v3M8.7 12h.01M15.3 12h.01M9.2 15.3c1.67 1.08 3.93 1.08 5.6 0M3.5 11.5v3M20.5 11.5v3", stroke: "currentColor", strokeWidth: "1.7", @@ -2005,16 +2205,16 @@ function RobotIcon({ size = 26 }) { ); } function AlertIcon({ size = 22 }) { - return h3( + return h4( SvgIcon, { size }, - h3("path", { + h4("path", { d: "M12 3.4 21 19H3L12 3.4Z", stroke: "currentColor", strokeWidth: "1.7", strokeLinejoin: "round" }), - h3("path", { + h4("path", { d: "M12 9v4.4M12 16.6v.01", stroke: "currentColor", strokeWidth: "1.9", @@ -2023,82 +2223,98 @@ function AlertIcon({ size = 22 }) { ); } function QrIcon({ size = 58 }) { - return h3(SvgIcon, { size }, h3("path", { + return h4(SvgIcon, { size }, h4("path", { d: "M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h2v2h-2v-2Zm4 0h2v4h-2v-4Zm-4 4h4v2h-4v-2Z", fill: "currentColor" })); } -var Button3 = React4.forwardRef(function Button4({ children, kind = "secondary", size, icon, className = "", ...props }, ref) { - return h3("button", { +var Button3 = React5.forwardRef(function Button4({ children, kind = "secondary", size, icon, className = "", ...props }, ref) { + return h4("button", { ...props, ref, type: "button", className: `bxf-button ${className}`.trim(), "data-kind": kind, "data-size": size - }, icon, h3("span", null, children)); + }, icon, h4("span", null, children)); }); function BrandMark() { - return h3("div", { className: "bxf-brandMark" }, h3(RobotIcon, { size: 34 })); + return h4("div", { className: "bxf-brandMark" }, h4(RobotIcon, { size: 34 })); } -function Heading2({ totals, onAdd, adding, busy, addButtonRef }) { +function Heading2({ totals, onAdd, onCredential, credentialOpen, adding, busy, addButtonRef }) { const hasBots = totals.configured > 0; - return h3( + return h4( "div", { className: "bxf-heading" }, - h3( + h4( "div", { className: "bxf-headingTools" }, - h3(Button3, { - kind: "primary", - size: "small", - className: "bxf-bindButton dim-scanButton", - onClick: onAdd, - disabled: adding || busy, - ref: addButtonRef, - "aria-busy": busy ? "true" : void 0 - }, adding ? "\u6B63\u5728\u7ED1\u5B9A" : "\u626B\u7801\u7ED1\u5B9A\u673A\u5668\u4EBA"), - hasBots ? h3("div", { + h4( + "div", + { className: "dim-bindActions" }, + h4(Button3, { + kind: "primary", + size: "small", + className: "bxf-bindButton dim-scanButton", + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + "aria-busy": busy ? "true" : void 0, + "aria-label": "\u626B\u7801\u63A5\u5165\u98DE\u4E66\u673A\u5668\u4EBA", + icon: h4(QrActionIcon) + }, adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), + h4(Button3, { + kind: "credential", + size: "small", + className: "dim-credentialButton", + onClick: onCredential, + disabled: adding || busy, + "aria-pressed": credentialOpen, + "aria-label": "\u4F7F\u7528 App ID \u548C App Secret \u7ED1\u5B9A\u98DE\u4E66\u673A\u5668\u4EBA", + icon: h4(CredentialActionIcon) + }, credentialOpen ? "\u6536\u8D77\u51ED\u636E" : "\u624B\u52A8\u63A5\u5165") + ), + hasBots ? h4("div", { className: "bxf-totalBadge dim-onlineBadge", "aria-label": `\u5DF2\u63A5\u5165 ${totals.configured} \u4E2A\u673A\u5668\u4EBA\uFF0C\u5176\u4E2D ${totals.connected} \u4E2A\u5728\u7EBF` - }, h3("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`)) : null + }, h4("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`)) : null ) ); } function LoadingView2() { - return h3( + return h4( "div", { className: "bxf-card dim-surfaceCard dim-loadingView", "aria-busy": "true", "aria-label": "\u6B63\u5728\u8BFB\u53D6\u98DE\u4E66\u673A\u5668\u4EBA\u5217\u8868" }, - h3("div", { className: "dim-spinner", "aria-hidden": "true" }), - h3("span", null, "\u6B63\u5728\u8BFB\u53D6\u98DE\u4E66\u8FDE\u63A5\u72B6\u6001\u2026") + h4("div", { className: "dim-spinner", "aria-hidden": "true" }), + h4("span", null, "\u6B63\u5728\u8BFB\u53D6\u98DE\u4E66\u8FDE\u63A5\u72B6\u6001\u2026") ); } function EmptyView2({ onStart, busy }) { - return h3( + return h4( "div", { className: "bxf-card dim-surfaceCard" }, - h3( + h4( "div", { className: "bxf-cardBody bxf-intro dim-surfaceBody dim-emptyView" }, - h3( + h4( "div", { className: "bxf-introCopy dim-emptyCopy" }, - h3( + h4( "div", { className: "bxf-stateLabel dim-stateLabel" }, - h3("span", { className: "bxf-dot dim-stateDot" }), - h3("span", null, "\u5C1A\u672A\u63A5\u5165\u673A\u5668\u4EBA") + h4("span", { className: "bxf-dot dim-stateDot" }), + h4("span", null, "\u5C1A\u672A\u63A5\u5165\u673A\u5668\u4EBA") ), - h3("h3", null, "\u626B\u7801\uFF0C\u521B\u5EFA\u7B2C\u4E00\u4E2A\u98DE\u4E66\u5165\u53E3"), - h3("p", null, "\u65E0\u9700\u624B\u52A8\u586B\u5199 App ID\u3002\u4EE5\u540E\u8FD8\u53EF\u4EE5\u7EE7\u7EED\u6DFB\u52A0\u673A\u5668\u4EBA\uFF0C\u5206\u522B\u670D\u52A1\u4E0D\u540C\u56E2\u961F\u6216\u98DE\u4E66\u79DF\u6237\u3002"), - h3( + h4("h3", null, "\u626B\u7801\uFF0C\u521B\u5EFA\u7B2C\u4E00\u4E2A\u98DE\u4E66\u5165\u53E3"), + h4("p", null, "\u65E0\u9700\u624B\u52A8\u586B\u5199 App ID\u3002\u4EE5\u540E\u8FD8\u53EF\u4EE5\u7EE7\u7EED\u6DFB\u52A0\u673A\u5668\u4EBA\uFF0C\u5206\u522B\u670D\u52A1\u4E0D\u540C\u56E2\u961F\u6216\u98DE\u4E66\u79DF\u6237\u3002"), + h4( "div", { className: "bxf-actions dim-viewActions" }, - h3(Button3, { + h4(Button3, { kind: "primary", onClick: onStart, disabled: busy, @@ -2106,7 +2322,7 @@ function EmptyView2({ onStart, busy }) { }, busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210\u98DE\u4E66\u4E8C\u7EF4\u7801") ) ), - h3("div", { className: "bxf-markStage dim-emptyBrand", "aria-hidden": "true" }, h3(BrandMark)) + h4("div", { className: "bxf-markStage dim-emptyBrand", "aria-hidden": "true" }, h4(BrandMark)) ) ); } @@ -2124,93 +2340,93 @@ function safeQrSource2(value) { return /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value) ? value : void 0; } function QrPane({ provision, now, onRefresh, onCancel, busy }) { - const [imageFailed, setImageFailed] = React4.useState(false); + const [imageFailed, setImageFailed] = React5.useState(false); const qrSource = safeQrSource2(provision.qrCodeDataUrl); const href = safeVerificationHref(provision.verificationUrl); const remaining = Math.max(0, provision.expiresAt - now); const expired = provision.expired === true || remaining === 0; const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining)); - React4.useEffect(() => setImageFailed(false), [qrSource]); - return h3( + React5.useEffect(() => setImageFailed(false), [qrSource]); + return h4( "div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" }, - h3( + h4( "div", { className: "bxf-cardBody bxf-qrLayout dim-surfaceBody dim-qrLayout" }, - h3( + h4( "div", { className: "bxf-qrColumn dim-qrColumn" }, - h3( + h4( "div", { className: "bxf-qrFrame dim-qrFrame" }, - qrSource && !imageFailed ? h3("img", { + qrSource && !imageFailed ? h4("img", { src: qrSource, alt: "\u7528\u4E8E\u65B0\u589E DeepSeek Harness \u98DE\u4E66\u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801", onError: () => setImageFailed(true) - }) : h3( + }) : h4( "div", { className: "bxf-qrFallback dim-qrFallback" }, - h3("div", null, h3(QrIcon), h3("span", null, "\u4E8C\u7EF4\u7801\u672A\u5C31\u7EEA\uFF0C\u8BF7\u6253\u5F00\u6388\u6743\u94FE\u63A5")) + h4("div", null, h4(QrIcon), h4("span", null, "\u4E8C\u7EF4\u7801\u672A\u5C31\u7EEA\uFF0C\u8BF7\u6253\u5F00\u6388\u6743\u94FE\u63A5")) ), - expired ? h3( + expired ? h4( "div", { className: "bxf-expiredOverlay dim-qrExpired", role: "status" }, - h3("div", null, "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548", h3("br"), "\u8BF7\u5237\u65B0\u540E\u91CD\u65B0\u626B\u7801") + h4("div", null, "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548", h4("br"), "\u8BF7\u5237\u65B0\u540E\u91CD\u65B0\u626B\u7801") ) : null ), - h3( + h4( "div", { className: "bxf-countdown dim-countdown", "aria-label": expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : `\u4E8C\u7EF4\u7801\u5269\u4F59 ${formatRemaining2(remaining)}` }, - h3( + h4( "div", { className: "bxf-countdownTop dim-countdownTop", "aria-hidden": "true" }, - h3("span", null, expired ? "\u7B49\u5F85\u5237\u65B0" : "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), - h3("strong", null, formatRemaining2(remaining)) + h4("span", null, expired ? "\u7B49\u5F85\u5237\u65B0" : "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), + h4("strong", null, formatRemaining2(remaining)) ), - h3( + h4( "div", { className: "bxf-progress dim-progress", "aria-hidden": "true" }, - h3("span", { style: { "--bxf-progress": `${Math.round(progress * 100)}%` } }) + h4("span", { style: { "--bxf-progress": `${Math.round(progress * 100)}%` } }) ) ) ), - h3( + h4( "div", { className: "bxf-qrCopy dim-qrCopy" }, - h3( + h4( "div", { className: "bxf-stateLabel dim-stateLabel" }, - h3("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }), - h3("span", null, "\u6B63\u5728\u6DFB\u52A0\u65B0\u673A\u5668\u4EBA") + h4("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }), + h4("span", null, "\u6B63\u5728\u6DFB\u52A0\u65B0\u673A\u5668\u4EBA") ), - h3("h3", null, expired ? "\u5237\u65B0\u4E8C\u7EF4\u7801\u540E\u7EE7\u7EED" : "\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u521B\u5EFA\u673A\u5668\u4EBA"), - h3("p", null, "\u626B\u7801\u53EA\u4F1A\u65B0\u589E\u4E00\u4E2A\u673A\u5668\u4EBA\uFF0C\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA\u4F1A\u7EE7\u7EED\u6B63\u5E38\u6536\u53D1\u6D88\u606F\u3002"), - h3( + h4("h3", null, expired ? "\u5237\u65B0\u4E8C\u7EF4\u7801\u540E\u7EE7\u7EED" : "\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u521B\u5EFA\u673A\u5668\u4EBA"), + h4("p", null, "\u626B\u7801\u53EA\u4F1A\u65B0\u589E\u4E00\u4E2A\u673A\u5668\u4EBA\uFF0C\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA\u4F1A\u7EE7\u7EED\u6B63\u5E38\u6536\u53D1\u6D88\u606F\u3002"), + h4( "ol", { className: "bxf-steps dim-steps" }, - h3("li", null, "\u6253\u5F00\u98DE\u4E66\u79FB\u52A8\u7AEF\uFF0C\u4F7F\u7528\u626B\u4E00\u626B\u8BFB\u53D6\u4E8C\u7EF4\u7801"), - h3("li", null, "\u6838\u5BF9\u5E94\u7528\u540D\u79F0\u4E0E\u6743\u9650\u8303\u56F4\uFF0C\u5E76\u786E\u8BA4\u521B\u5EFA"), - h3("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u65B0\u673A\u5668\u4EBA\u7684\u957F\u8FDE\u63A5\u5C31\u7EEA") + h4("li", null, "\u6253\u5F00\u98DE\u4E66\u79FB\u52A8\u7AEF\uFF0C\u4F7F\u7528\u626B\u4E00\u626B\u8BFB\u53D6\u4E8C\u7EF4\u7801"), + h4("li", null, "\u6838\u5BF9\u5E94\u7528\u540D\u79F0\u4E0E\u6743\u9650\u8303\u56F4\uFF0C\u5E76\u786E\u8BA4\u521B\u5EFA"), + h4("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u65B0\u673A\u5668\u4EBA\u7684\u957F\u8FDE\u63A5\u5C31\u7EEA") ), - h3( + h4( "div", { className: "bxf-actions dim-viewActions" }, - expired ? h3(Button3, { + expired ? h4(Button3, { kind: "primary", onClick: onRefresh, disabled: busy - }, busy ? "\u5237\u65B0\u4E2D\u2026" : "\u5237\u65B0\u4E8C\u7EF4\u7801") : href ? h3("a", { + }, busy ? "\u5237\u65B0\u4E2D\u2026" : "\u5237\u65B0\u4E8C\u7EF4\u7801") : href ? h4("a", { className: "bxf-button bxf-link", "data-kind": "secondary", href, target: "_blank", rel: "noopener noreferrer" - }, h3("span", null, "\u5728\u98DE\u4E66\u4E2D\u6253\u5F00")) : null, - !expired ? h3(Button3, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, - h3(Button3, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u6DFB\u52A0") + }, h4("span", null, "\u5728\u98DE\u4E66\u4E2D\u6253\u5F00")) : null, + !expired ? h4(Button3, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, + h4(Button3, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u6DFB\u52A0") ) ) ) @@ -2218,44 +2434,44 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) { } function ProvisionProgress({ phase, onCancel, busy }) { const connecting = phase === "connecting"; - return h3( + return h4( "div", { className: "bxf-card bxf-provisionCard dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h3("div", { className: "dim-spinner", "aria-hidden": "true" }), - h3("h3", null, connecting ? "\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u8FDE\u63A5\u65B0\u673A\u5668\u4EBA" : "\u6B63\u5728\u51C6\u5907\u6388\u6743\u4E8C\u7EF4\u7801"), - h3("p", null, connecting ? "\u6B63\u5728\u5B89\u5168\u4FDD\u5B58\u51ED\u636E\u5E76\u68C0\u67E5\u65B0\u673A\u5668\u4EBA\u7684\u6D88\u606F\u901A\u9053\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002" : "\u6B63\u5728\u5411\u98DE\u4E66\u7533\u8BF7\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002"), - connecting ? h3( + h4("div", { className: "dim-spinner", "aria-hidden": "true" }), + h4("h3", null, connecting ? "\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u8FDE\u63A5\u65B0\u673A\u5668\u4EBA" : "\u6B63\u5728\u51C6\u5907\u6388\u6743\u4E8C\u7EF4\u7801"), + h4("p", null, connecting ? "\u6B63\u5728\u5B89\u5168\u4FDD\u5B58\u51ED\u636E\u5E76\u68C0\u67E5\u65B0\u673A\u5668\u4EBA\u7684\u6D88\u606F\u901A\u9053\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002" : "\u6B63\u5728\u5411\u98DE\u4E66\u7533\u8BF7\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002"), + connecting ? h4( "div", { className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } }, - h3(Button3, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u6DFB\u52A0") + h4(Button3, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u6DFB\u52A0") ) : null ); } function ProvisionError2({ error, onRetry, onCancel, busy }) { - return h3( + return h4( "div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" }, - h3( + h4( "div", { className: "bxf-inlineError dim-inlineError", role: "alert" }, - h3( + h4( "div", null, - h3("h3", null, "\u65B0\u673A\u5668\u4EBA\u6CA1\u6709\u6DFB\u52A0\u5B8C\u6210"), - h3("p", null, error.message), - error.code ? h3("span", { className: "bxf-errorCode" }, error.code) : null, - h3( + h4("h3", null, "\u65B0\u673A\u5668\u4EBA\u6CA1\u6709\u6DFB\u52A0\u5B8C\u6210"), + h4("p", null, error.message), + error.code ? h4("span", { className: "bxf-errorCode" }, error.code) : null, + h4( "div", { className: "bxf-actions dim-viewActions" }, - h3( + h4( Button3, { kind: "primary", onClick: onRetry, disabled: busy }, busy ? "\u91CD\u8BD5\u4E2D\u2026" : "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801" ), - h3(Button3, { onClick: onCancel, disabled: busy }, "\u5173\u95ED") + h4(Button3, { onClick: onCancel, disabled: busy }, "\u5173\u95ED") ) ) ) @@ -2280,12 +2496,12 @@ function formatCheckedTime(timestamp5) { } } function RemoveConfirmation2({ bot, busy, onConfirm, onCancel }) { - const cancelRef = React4.useRef(null); + const cancelRef = React5.useRef(null); const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-"); const titleId = `bxf-remove-title-${idPart}`; const descriptionId = `bxf-remove-description-${idPart}`; - React4.useEffect(() => cancelRef.current?.focus(), []); - return h3( + React5.useEffect(() => cancelRef.current?.focus(), []); + return h4( "div", { className: "bxf-confirm dim-confirm", @@ -2299,17 +2515,17 @@ function RemoveConfirmation2({ bot, busy, onConfirm, onCancel }) { } } }, - h3("h4", { id: titleId }, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${bot.bot.name}\u201D\uFF1F`), - h3( + h4("h4", { id: titleId }, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${bot.bot.name}\u201D\uFF1F`), + h4( "p", { id: descriptionId }, "\u6B64\u64CD\u4F5C\u4F1A\u505C\u6B62\u8FD9\u4E2A\u673A\u5668\u4EBA\u7684\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u4FDD\u5B58\u5728\u672C\u673A\u7684\u63A5\u5165\u914D\u7F6E\u548C\u51ED\u636E\u3002\u98DE\u4E66\u5F00\u653E\u5E73\u53F0\u4E2D\u7684\u5E94\u7528\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E5F\u4E0D\u53D7\u5F71\u54CD\u3002" ), - h3( + h4( "div", { className: "bxf-actions dim-viewActions" }, - h3(Button3, { ref: cancelRef, onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), - h3( + h4(Button3, { ref: cancelRef, onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), + h4( Button3, { kind: "danger", onClick: onConfirm, disabled: busy }, busy ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664\u63A5\u5165" @@ -2332,9 +2548,9 @@ function BotCard({ const { bot, health, state, connected } = connection; const stateForDisplay = busy === "reconnect" ? "connecting" : state; const tone = stateForDisplay === "connected" ? "success" : stateForDisplay === "connecting" ? "warning" : "error"; - const summary = actionError?.message ?? connection.error?.message ?? health.summary; + const summary = actionError?.message ?? connection.error?.message ?? (connected ? null : health.summary); const titleId = `bxf-bot-${connection.botId.replace(/[^a-zA-Z0-9_-]/g, "-")}`; - return h3( + return h4( "article", { className: "bxf-card bxf-botCard dim-botCard", @@ -2343,69 +2559,69 @@ function BotCard({ tabIndex: -1, ref: cardRef }, - h3( + h4( "div", { className: "bxf-cardBody dim-botCardBody" }, - h3( + h4( "div", { className: "bxf-connectedTop dim-botCardTop" }, - h3( + h4( "div", { className: "bxf-botIdentity dim-botIdentity" }, - h3( + h4( "div", { className: "bxf-avatar dim-botAvatar", "aria-hidden": "true" }, - h3(FeishuLogoGlyph, { size: 34 }) + h4(FeishuLogoGlyph, { size: 34 }) ), - h3( + h4( "div", { className: "bxf-botName dim-botName" }, - h3("h3", { id: titleId, title: bot.name }, bot.name), - h3("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58") + h4("h3", { id: titleId, title: bot.name }, bot.name), + h4("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58") ) ), - h3( + h4( "div", { className: "bxf-healthPill dim-botHealth", "data-health": stateForDisplay }, - h3("span", { className: "bxf-dot dim-healthDot", "data-tone": tone }), - h3("span", null, HEALTH_LABELS[stateForDisplay] ?? "\u72B6\u6001\u672A\u77E5") + h4("span", { className: "bxf-dot dim-healthDot", "data-tone": tone }), + h4("span", null, HEALTH_LABELS[stateForDisplay] ?? "\u72B6\u6001\u672A\u77E5") ) ), - h3( + h4( "dl", { className: "bxf-statusGrid dim-botMetrics" }, - h3( + h4( "div", { className: "bxf-metric dim-botMetric" }, - h3("dt", null, "\u6D88\u606F\u901A\u9053"), - h3("dd", null, connected ? "\u957F\u8FDE\u63A5" : stateForDisplay === "connecting" ? "\u8FDE\u63A5\u4E2D" : "\u5DF2\u65AD\u5F00") + h4("dt", null, "\u6D88\u606F\u901A\u9053"), + h4("dd", null, connected ? "\u957F\u8FDE\u63A5" : stateForDisplay === "connecting" ? "\u8FDE\u63A5\u4E2D" : "\u5DF2\u65AD\u5F00") ), - h3( + h4( "div", { className: "bxf-metric dim-botMetric" }, - h3("dt", null, "\u6700\u8FD1\u68C0\u67E5"), - h3("dd", null, formatCheckedTime(health.lastCheckedAt)) + h4("dt", null, "\u6700\u8FD1\u68C0\u67E5"), + h4("dd", null, formatCheckedTime(health.lastCheckedAt)) ) ), - h3( + h4( "div", { className: "bxf-connectedFooter dim-cardFooter" }, - h3( + summary ? h4( "div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : void 0 }, summary - ), - h3( + ) : null, + h4( "div", { className: "bxf-actions bxf-botActions dim-cardActions" }, - h3(Button3, { + h4(Button3, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy), "aria-busy": busy === "reconnect" ? "true" : void 0, "aria-label": `${connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"}${bot.name}` }, busy === "reconnect" ? connected ? "\u68C0\u67E5\u4E2D\u2026" : "\u6B63\u5728\u8FDE\u63A5\u2026" : connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"), - h3(Button3, { + h4(Button3, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, @@ -2416,7 +2632,7 @@ function BotCard({ ) ) ), - removing ? h3(RemoveConfirmation2, { + removing ? h4(RemoveConfirmation2, { bot: connection, busy: busy === "delete", onConfirm: onConfirmRemove, @@ -2425,22 +2641,21 @@ function BotCard({ ); } function BotList(props) { - return h3( + return h4( "section", { className: "bxf-listSection dim-listSection", "aria-labelledby": "bxf-bot-list-title" }, - h3( + h4( "div", { className: "bxf-listHeading dim-listHeading" }, - h3("h3", { id: "bxf-bot-list-title" }, "\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA"), - h3("span", null, `${props.bots.length} \u4E2A`) + h4("h3", { id: "bxf-bot-list-title" }, "\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA") ), - h3( + h4( "ul", { className: "bxf-botList dim-botList", role: "list" }, - props.bots.map((bot) => h3( + props.bots.map((bot) => h4( "li", { key: bot.botId }, - h3(BotCard, { + h4(BotCard, { connection: bot, busy: props.busyByBot[bot.botId], actionError: props.errorsByBot[bot.botId], @@ -2457,22 +2672,22 @@ function BotList(props) { ); } function PageError({ error, onRetry, busy }) { - return h3( + return h4( "div", { className: "bxf-card dim-surfaceCard" }, - h3( + h4( "div", { className: "bxf-error dim-inlineError", role: "alert" }, - h3( + h4( "div", null, - h3("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u98DE\u4E66\u673A\u5668\u4EBA"), - h3("p", null, error.message), - error.code ? h3("span", { className: "bxf-errorCode" }, error.code) : null, - h3( + h4("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u98DE\u4E66\u673A\u5668\u4EBA"), + h4("p", null, error.message), + error.code ? h4("span", { className: "bxf-errorCode" }, error.code) : null, + h4( "div", { className: "bxf-actions dim-viewActions" }, - h3( + h4( Button3, { kind: "primary", onClick: onRetry, disabled: busy }, busy ? "\u91CD\u8BD5\u4E2D\u2026" : "\u91CD\u65B0\u8BFB\u53D6" @@ -2506,7 +2721,7 @@ function mergeFeishuSnapshotState(current, snapshot, { restoreProvisioning = fal }; } function FeishuSettingsTab({ rpcCall }) { - const [model, setModel] = React4.useState({ + const [model, setModel] = React5.useState({ phase: "loading", revision: 0, bots: [], @@ -2515,28 +2730,31 @@ function FeishuSettingsTab({ rpcCall }) { pageError: null, statusError: null }); - const [pageBusy, setPageBusy] = React4.useState(false); - const [provisionBusy, setProvisionBusy] = React4.useState(false); - const [busyByBot, setBusyByBot] = React4.useState({}); - const [errorsByBot, setErrorsByBot] = React4.useState({}); - const [removeTargetId, setRemoveTargetId] = React4.useState(null); - const [announcement, setAnnouncement] = React4.useState(""); - const [now, setNow] = React4.useState(() => Date.now()); - const [focusBotId, setFocusBotId] = React4.useState(null); - const cardRefs = React4.useRef(/* @__PURE__ */ new Map()); - const removeButtonRefs = React4.useRef(/* @__PURE__ */ new Map()); - const addButtonRef = React4.useRef(null); + const [pageBusy, setPageBusy] = React5.useState(false); + const [provisionBusy, setProvisionBusy] = React5.useState(false); + const [credentialOpen, setCredentialOpen] = React5.useState(false); + const [credentialBusy, setCredentialBusy] = React5.useState(false); + const [credentialError, setCredentialError] = React5.useState(null); + const [busyByBot, setBusyByBot] = React5.useState({}); + const [errorsByBot, setErrorsByBot] = React5.useState({}); + const [removeTargetId, setRemoveTargetId] = React5.useState(null); + const [announcement, setAnnouncement] = React5.useState(""); + const [now, setNow] = React5.useState(() => Date.now()); + const [focusBotId, setFocusBotId] = React5.useState(null); + const cardRefs = React5.useRef(/* @__PURE__ */ new Map()); + const removeButtonRefs = React5.useRef(/* @__PURE__ */ new Map()); + const addButtonRef = React5.useRef(null); const scheduleAnimationFrame = useAnimationFrameScheduler(); - const announce = React4.useCallback((message) => { + const announce = React5.useCallback((message) => { setAnnouncement(""); scheduleAnimationFrame(() => { if (message) setAnnouncement(message); }, "announcement"); }, [scheduleAnimationFrame]); - const invoke = React4.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React5.useCallback(async (endpoint, payload = {}, signal) => { return unwrapRpcResult2(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const mergeSnapshot = React4.useCallback((snapshot, { restoreProvisioning = false } = {}) => { + const mergeSnapshot = React5.useCallback((snapshot, { restoreProvisioning = false } = {}) => { const now2 = Date.now(); setModel((current) => mergeFeishuSnapshotState( current, @@ -2544,7 +2762,7 @@ function FeishuSettingsTab({ rpcCall }) { { restoreProvisioning, now: now2 } )); }, []); - const loadStatus = React4.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { + const loadStatus = React5.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { if (!silent) setPageBusy(true); try { const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal)); @@ -2560,12 +2778,12 @@ function FeishuSettingsTab({ rpcCall }) { if (!silent && !signal?.aborted) setPageBusy(false); } }, [invoke, mergeSnapshot]); - React4.useEffect(() => { + React5.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); - React4.useEffect(() => { + React5.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); let inFlight = false; @@ -2584,7 +2802,7 @@ function FeishuSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React4.useEffect(() => { + React5.useEffect(() => { if (!focusBotId) return; const node = cardRefs.current.get(focusBotId); if (!node) return; @@ -2592,7 +2810,9 @@ function FeishuSettingsTab({ rpcCall }) { node.focus({ preventScroll: true }); setFocusBotId(null); }, [focusBotId, model.bots]); - const startProvisioning = React4.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React5.useCallback(async ({ replace = false } = {}) => { + setCredentialOpen(false); + setCredentialError(null); setProvisionBusy(true); announce(""); const previousAttemptId = model.provisioning?.attemptId; @@ -2630,7 +2850,24 @@ function FeishuSettingsTab({ rpcCall }) { setProvisionBusy(false); } }, [announce, invoke, model.provisioning?.attemptId]); - const cancelProvisioning = React4.useCallback(async () => { + const bindCredentials = React5.useCallback(async ({ identity, secret }) => { + setCredentialBusy(true); + setCredentialError(null); + try { + const snapshot = normalizeBotsSnapshot(await invoke( + FEISHU_ENDPOINTS.bindCredentials, + { appId: identity, appSecret: secret } + )); + mergeSnapshot(snapshot); + setCredentialOpen(false); + announce("\u98DE\u4E66\u673A\u5668\u4EBA\u51ED\u636E\u5DF2\u7ED1\u5B9A\u3002"); + } catch (error) { + setCredentialError(presentError2(error)); + } finally { + setCredentialBusy(false); + } + }, [announce, invoke, mergeSnapshot]); + const cancelProvisioning = React5.useCallback(async () => { const attemptId = model.provisioning?.attemptId; setProvisionBusy(true); try { @@ -2652,7 +2889,7 @@ function FeishuSettingsTab({ rpcCall }) { const countdownPhase = model.provisioning?.phase; const countdownExpiresAt = model.provisioning?.expiresAt; const countdownExpired = model.provisioning?.expired; - React4.useEffect(() => { + React5.useEffect(() => { if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return void 0; const tick = () => { const timestamp5 = Date.now(); @@ -2665,7 +2902,7 @@ function FeishuSettingsTab({ rpcCall }) { const timer = window.setInterval(tick, 1e3); return () => window.clearInterval(timer); }, [countdownAttemptId, countdownPhase, countdownExpiresAt, countdownExpired]); - React4.useEffect(() => { + React5.useEffect(() => { const provision2 = model.provisioning; if (!provision2 || !["qr", "connecting"].includes(provision2.phase) || !provision2.attemptId || provision2.expired) return void 0; const controller = new AbortController(); @@ -2729,7 +2966,7 @@ function FeishuSettingsTab({ rpcCall }) { window.clearTimeout(timer); }; }, [announce, invoke, loadStatus, model.provisioning]); - const setBotBusy = React4.useCallback((botId, value) => { + const setBotBusy = React5.useCallback((botId, value) => { setBusyByBot((current) => { const next = { ...current }; if (value) next[botId] = value; @@ -2737,7 +2974,7 @@ function FeishuSettingsTab({ rpcCall }) { return next; }); }, []); - const setBotError = React4.useCallback((botId, error) => { + const setBotError = React5.useCallback((botId, error) => { setErrorsByBot((current) => { const next = { ...current }; if (error) next[botId] = presentError2(error); @@ -2745,7 +2982,7 @@ function FeishuSettingsTab({ rpcCall }) { return next; }); }, []); - const reconnectOneBot = React4.useCallback(async (connection) => { + const reconnectOneBot = React5.useCallback(async (connection) => { const { botId, bot } = connection; setBotBusy(botId, "reconnect"); setBotError(botId, null); @@ -2768,15 +3005,15 @@ function FeishuSettingsTab({ rpcCall }) { setBotBusy(botId, null); } }, [announce, invoke, mergeSnapshot, setBotBusy, setBotError]); - const requestRemove = React4.useCallback((connection) => { + const requestRemove = React5.useCallback((connection) => { setRemoveTargetId(connection.botId); }, []); - const cancelRemove = React4.useCallback(() => { + const cancelRemove = React5.useCallback(() => { const botId = removeTargetId; setRemoveTargetId(null); scheduleAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus(), "focus"); }, [removeTargetId, scheduleAnimationFrame]); - const confirmRemove = React4.useCallback(async (connection) => { + const confirmRemove = React5.useCallback(async (connection) => { const { botId, bot } = connection; setBotBusy(botId, "delete"); setBotError(botId, null); @@ -2807,9 +3044,9 @@ function FeishuSettingsTab({ rpcCall }) { const provision = model.provisioning; let provisionContent = null; if (provision?.phase === "creating") { - provisionContent = h3(ProvisionProgress, { phase: "creating", busy: provisionBusy }); + provisionContent = h4(ProvisionProgress, { phase: "creating", busy: provisionBusy }); } else if (provision?.phase === "qr") { - provisionContent = h3(QrPane, { + provisionContent = h4(QrPane, { provision, now, onRefresh: () => void startProvisioning({ replace: true }), @@ -2817,60 +3054,80 @@ function FeishuSettingsTab({ rpcCall }) { busy: provisionBusy || model.phase !== "ready" }); } else if (provision?.phase === "connecting") { - provisionContent = h3(ProvisionProgress, { + provisionContent = h4(ProvisionProgress, { phase: "connecting", onCancel: () => void cancelProvisioning(), busy: provisionBusy }); } else if (provision?.phase === "error") { - provisionContent = h3(ProvisionError2, { + provisionContent = h4(ProvisionError2, { error: provision.error, onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }), onCancel: () => void cancelProvisioning(), busy: provisionBusy }); } - const setCardRef = React4.useCallback((botId, node) => { + const credentialContent = credentialOpen ? h4(CredentialBindingPanel, { + channel: "\u98DE\u4E66", + identityLabel: "App ID", + identityPlaceholder: "\u586B\u5199\u98DE\u4E66\u5F00\u653E\u5E73\u53F0 App ID", + secretLabel: "App Secret", + secretPlaceholder: "\u586B\u5199\u98DE\u4E66\u5F00\u653E\u5E73\u53F0 App Secret", + busy: credentialBusy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { + setCredentialOpen(false); + setCredentialError(null); + } + }) : null; + const setCardRef = React5.useCallback((botId, node) => { if (node) cardRefs.current.set(botId, node); else cardRefs.current.delete(botId); }, []); - const setRemoveButtonRef = React4.useCallback((botId, node) => { + const setRemoveButtonRef = React5.useCallback((botId, node) => { if (node) removeButtonRefs.current.set(botId, node); else removeButtonRefs.current.delete(botId); }, []); - return h3( + return h4( "section", { className: "bxf-page dim-channelPage", "aria-label": "\u98DE\u4E66\u673A\u5668\u4EBA\u8BBE\u7F6E" }, - h3(Heading2, { + h4(Heading2, { totals: model.totals, onAdd: () => void startProvisioning(), + onCredential: () => { + setCredentialOpen((value) => !value); + setCredentialError(null); + }, + credentialOpen, adding: Boolean(provision), - busy: provisionBusy, + busy: provisionBusy || credentialBusy, addButtonRef }), - h3("div", { + h4("div", { className: "bxf-visuallyHidden", role: "status", "aria-live": "polite", "aria-atomic": "true" }, announcement), - model.statusError ? h3( + model.statusError ? h4( "div", { className: "bxf-statusNotice dim-statusNotice", role: "status" }, - h3(AlertIcon, { size: 16 }), - h3("span", null, `\u72B6\u6001\u81EA\u52A8\u5237\u65B0\u5931\u8D25\uFF1A${model.statusError.message}`), - h3(Button3, { size: "small", onClick: () => void loadStatus({ silent: true }), disabled: pageBusy }, "\u7ACB\u5373\u91CD\u8BD5") + h4(AlertIcon, { size: 16 }), + h4("span", null, `\u72B6\u6001\u81EA\u52A8\u5237\u65B0\u5931\u8D25\uFF1A${model.statusError.message}`), + h4(Button3, { size: "small", onClick: () => void loadStatus({ silent: true }), disabled: pageBusy }, "\u7ACB\u5373\u91CD\u8BD5") ) : null, - model.phase === "loading" ? h3(LoadingView2) : model.phase === "error" ? h3(PageError, { + model.phase === "loading" ? h4(LoadingView2) : model.phase === "error" ? h4(PageError, { error: model.pageError ?? { message: "\u65E0\u6CD5\u8BFB\u53D6\u8FDE\u63A5\u72B6\u6001" }, onRetry: () => void loadStatus(), busy: pageBusy - }) : h3( - React4.Fragment, + }) : h4( + React5.Fragment, null, + credentialContent, provisionContent, - model.bots.length === 0 && !provision ? h3(EmptyView2, { onStart: () => void startProvisioning(), busy: provisionBusy }) : null, - model.bots.length > 0 ? h3(BotList, { + model.bots.length === 0 && !provision && !credentialOpen ? h4(EmptyView2, { onStart: () => void startProvisioning(), busy: provisionBusy }) : null, + model.bots.length > 0 ? h4(BotList, { bots: model.bots, busyByBot, errorsByBot, @@ -2893,6 +3150,7 @@ var QQ_ENDPOINTS = Object.freeze({ beginProvisioning: "provision.begin", pollProvisioning: "provision.poll", cancelProvisioning: "provision.cancel", + bindCredentials: "bot.bind-credentials", reconnectBot: "bot.reconnect", deleteBot: "bot.delete" }); @@ -2993,7 +3251,7 @@ function formatRemaining3(milliseconds) { } // plugin-src/client/channels/qq/index.js -var React5 = __toESM(require("react"), 1); +var React6 = __toESM(require("react"), 1); // plugin-src/client/channels/qq/styles.js var QQ_STYLE_ID = "xmanrui-dsh-im-qq-settings"; @@ -3017,10 +3275,10 @@ function installQqStyles() { } // plugin-src/client/channels/qq/index.js -var h4 = React5.createElement; +var h5 = React6.createElement; var ACTIVE_STATES = /* @__PURE__ */ new Set(["pending", "refreshing", "connecting"]); -var Button5 = React5.forwardRef(function Button6({ children, kind = "secondary", className = "", ...props }, ref) { - return h4("button", { +var Button5 = React6.forwardRef(function Button6({ children, kind = "secondary", className = "", ...props }, ref) { + return h5("button", { ...props, ref, type: "button", @@ -3040,68 +3298,81 @@ function checkedTime2(value) { return "\u521A\u521A"; } } -function Heading3({ totals, adding, busy, onAdd, addButtonRef }) { - return h4( +function Heading3({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) { + return h5( "div", { className: "ddt-heading" }, - h4( + h5( "div", { className: "ddt-tools" }, - h4(Button5, { - kind: "primary", - className: "dim-scanButton", - onClick: onAdd, - disabled: adding || busy, - ref: addButtonRef - }, adding ? "\u6B63\u5728\u7ED1\u5B9A" : "\u626B\u7801\u7ED1\u5B9AQQ\u673A\u5668\u4EBA"), - totals.configured > 0 ? h4( + h5( + "div", + { className: "dim-bindActions" }, + h5(Button5, { + kind: "primary", + className: "dim-scanButton", + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + "aria-label": "\u626B\u7801\u63A5\u5165 QQ \u673A\u5668\u4EBA" + }, h5(QrActionIcon), adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), + h5(Button5, { + kind: "credential", + className: "dim-credentialButton", + onClick: onCredential, + disabled: adding || busy, + "aria-pressed": credentialOpen, + "aria-label": "\u4F7F\u7528 AppID \u548C AppSecret \u7ED1\u5B9A QQ \u673A\u5668\u4EBA" + }, h5(CredentialActionIcon), credentialOpen ? "\u6536\u8D77\u51ED\u636E" : "\u624B\u52A8\u63A5\u5165") + ), + totals.configured > 0 ? h5( "div", { className: "ddt-badge dim-onlineBadge" }, - h4("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) + h5("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) ) : null ) ); } function LoadingView3() { - return h4( + return h5( "div", { className: "ddt-card ddt-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h4("div", { className: "ddt-spinner dim-spinner" }), - h4("span", null, "\u6B63\u5728\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001\u2026") + h5("div", { className: "ddt-spinner dim-spinner" }), + h5("span", null, "\u6B63\u5728\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001\u2026") ); } function EmptyView3({ busy, onStart }) { - return h4( + return h5( "div", { className: "ddt-card dim-surfaceCard" }, - h4( + h5( "div", { className: "ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView" }, - h4( + h5( "div", { className: "dim-emptyCopy" }, - h4( + h5( "div", { className: "ddt-stateLabel dim-stateLabel" }, - h4("span", { className: "ddt-dot dim-stateDot" }), - h4("span", null, "\u5C1A\u672A\u7ED1\u5B9A QQ \u673A\u5668\u4EBA") + h5("span", { className: "ddt-dot dim-stateDot" }), + h5("span", null, "\u5C1A\u672A\u7ED1\u5B9A QQ \u673A\u5668\u4EBA") ), - h4("h3", null, "\u4F7F\u7528\u624B\u673A QQ \u626B\u7801\u521B\u5EFA\u5E76\u7ED1\u5B9A\u673A\u5668\u4EBA"), - h4("p", null, "\u626B\u7801\u7531\u817E\u8BAF\u5B98\u65B9\u9875\u9762\u5B8C\u6210\uFF0C\u4E0D\u9700\u8981\u624B\u52A8\u586B\u5199 AppID \u6216 AppSecret\u3002\u626B\u7801\u6210\u529F\u540E\uFF0C\u673A\u5668\u4EBA\u4F1A\u81EA\u52A8\u8FDE\u63A5 DeepSeek Harness\u3002"), - h4( + h5("h3", null, "\u4F7F\u7528\u624B\u673A QQ \u626B\u7801\u521B\u5EFA\u5E76\u7ED1\u5B9A\u673A\u5668\u4EBA"), + h5("p", null, "\u626B\u7801\u7531\u817E\u8BAF\u5B98\u65B9\u9875\u9762\u5B8C\u6210\uFF0C\u4E0D\u9700\u8981\u624B\u52A8\u586B\u5199 AppID \u6216 AppSecret\u3002\u626B\u7801\u6210\u529F\u540E\uFF0C\u673A\u5668\u4EBA\u4F1A\u81EA\u52A8\u8FDE\u63A5 DeepSeek Harness\u3002"), + h5( "div", { className: "ddt-actions dim-viewActions" }, - h4( + h5( Button5, { kind: "primary", onClick: onStart, disabled: busy }, busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210 QQ \u4E8C\u7EF4\u7801" ) ) ), - h4( + h5( "div", { className: "ddt-brandMark dim-emptyBrand dqq-brand", "aria-hidden": "true" }, - h4(QqLogoGlyph, { size: 64 }) + h5(QqLogoGlyph, { size: 64 }) ) ) ); @@ -3112,59 +3383,59 @@ function QrPanel2({ provision, now, busy, onRefresh, onCancel }) { const duration = Math.max(1, provision.durationMs ?? 5 * 6e4); const progress = Math.round(Math.min(1, remaining / duration) * 100); const refreshing = provision.status === "refreshing"; - return h4( + return h5( "div", { className: "ddt-card dim-surfaceCard" }, - h4( + h5( "div", { className: "ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout" }, - h4( + h5( "div", { className: "ddt-qrColumn dim-qrColumn" }, - h4( + h5( "div", { className: "ddt-qrFrame dim-qrFrame" }, - source ? h4("img", { src: source, alt: "\u7528\u4E8E\u7ED1\u5B9A QQ \u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801" }) : h4( + source ? h5("img", { src: source, alt: "\u7528\u4E8E\u7ED1\u5B9A QQ \u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801" }) : h5( "div", { className: "ddt-qrFallback dim-qrFallback" }, refreshing ? "\u4E8C\u7EF4\u7801\u6B63\u5728\u81EA\u52A8\u5237\u65B0\u2026" : "\u4E8C\u7EF4\u7801\u56FE\u7247\u6B63\u5728\u751F\u6210\u2026" ) ), - h4( + h5( "div", { className: "ddt-countdown dim-countdown" }, - h4( + h5( "div", { className: "ddt-countdownTop dim-countdownTop" }, - h4("span", null, "\u5F53\u524D\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), - h4("strong", null, refreshing ? "--:--" : formatRemaining3(remaining)) + h5("span", null, "\u5F53\u524D\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), + h5("strong", null, refreshing ? "--:--" : formatRemaining3(remaining)) ), - h4("div", { className: "ddt-progress dim-progress", style: { "--ddt-progress": `${progress}%` } }, h4("span")) + h5("div", { className: "ddt-progress dim-progress", style: { "--ddt-progress": `${progress}%` } }, h5("span")) ) ), - h4( + h5( "div", { className: "ddt-qrCopy dim-qrCopy" }, - h4( + h5( "div", { className: "ddt-stateLabel dim-stateLabel" }, - h4("span", { className: "ddt-dot dim-stateDot", "data-tone": "warning" }), - h4("span", null, refreshing ? "\u6B63\u5728\u5237\u65B0\u4E8C\u7EF4\u7801" : "\u7B49\u5F85\u624B\u673A QQ \u626B\u7801") + h5("span", { className: "ddt-dot dim-stateDot", "data-tone": "warning" }), + h5("span", null, refreshing ? "\u6B63\u5728\u5237\u65B0\u4E8C\u7EF4\u7801" : "\u7B49\u5F85\u624B\u673A QQ \u626B\u7801") ), - h4("h3", null, "\u4F7F\u7528\u624B\u673A QQ \u5B8C\u6210\u673A\u5668\u4EBA\u7ED1\u5B9A"), - h4("p", null, "\u817E\u8BAF\u9875\u9762\u4F1A\u521B\u5EFA\u6216\u7ED1\u5B9A\u4E00\u4E2A QQ \u673A\u5668\u4EBA\uFF0C\u5E76\u628A\u8FDE\u63A5\u51ED\u636E\u5B89\u5168\u4EA4\u7ED9\u672C\u673A Harness Host\u3002"), - h4( + h5("h3", null, "\u4F7F\u7528\u624B\u673A QQ \u5B8C\u6210\u673A\u5668\u4EBA\u7ED1\u5B9A"), + h5("p", null, "\u817E\u8BAF\u9875\u9762\u4F1A\u521B\u5EFA\u6216\u7ED1\u5B9A\u4E00\u4E2A QQ \u673A\u5668\u4EBA\uFF0C\u5E76\u628A\u8FDE\u63A5\u51ED\u636E\u5B89\u5168\u4EA4\u7ED9\u672C\u673A Harness Host\u3002"), + h5( "ol", { className: "ddt-steps dim-steps" }, - h4("li", null, "\u6253\u5F00\u624B\u673A QQ\uFF0C\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), - h4("li", null, "\u5728\u817E\u8BAF\u6388\u6743\u9875\u9762\u786E\u8BA4\u521B\u5EFA\u6216\u7ED1\u5B9A\u673A\u5668\u4EBA"), - h4("li", null, "\u8FD4\u56DE\u8FD9\u91CC\u7B49\u5F85\u8FDE\u63A5\u5B8C\u6210") + h5("li", null, "\u6253\u5F00\u624B\u673A QQ\uFF0C\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), + h5("li", null, "\u5728\u817E\u8BAF\u6388\u6743\u9875\u9762\u786E\u8BA4\u521B\u5EFA\u6216\u7ED1\u5B9A\u673A\u5668\u4EBA"), + h5("li", null, "\u8FD4\u56DE\u8FD9\u91CC\u7B49\u5F85\u8FDE\u63A5\u5B8C\u6210") ), - h4( + h5( "div", { className: "ddt-actions dim-viewActions" }, - h4(Button5, { onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), - h4(Button5, { kind: "quiet", onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + h5(Button5, { onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h5(Button5, { kind: "quiet", onClick: onCancel, disabled: busy }, "\u53D6\u6D88") ) ) ) @@ -3172,96 +3443,97 @@ function QrPanel2({ provision, now, busy, onRefresh, onCancel }) { } function ProvisionView({ provision, busy, onRetry, onClose }) { if (provision.status === "connecting") { - return h4( + return h5( "div", { className: "ddt-card ddt-loading dim-surfaceCard dim-specialView", "aria-busy": "true" }, - h4("div", { className: "ddt-spinner dim-spinner" }), - h4("h3", null, "QQ \u5DF2\u6388\u6743\uFF0C\u6B63\u5728\u8FDE\u63A5\u673A\u5668\u4EBA"), - h4("p", null, "\u51ED\u636E\u6B63\u5728\u5199\u5165\u672C\u673A\uFF0C\u5E76\u542F\u52A8 QQ WebSocket \u6D88\u606F\u8FDE\u63A5\u3002") + h5("div", { className: "ddt-spinner dim-spinner" }), + h5("h3", null, "QQ \u5DF2\u6388\u6743\uFF0C\u6B63\u5728\u8FDE\u63A5\u673A\u5668\u4EBA"), + h5("p", null, "\u51ED\u636E\u6B63\u5728\u5199\u5165\u672C\u673A\uFF0C\u5E76\u542F\u52A8 QQ WebSocket \u6D88\u606F\u8FDE\u63A5\u3002") ); } const error = provision.error ?? { code: "QQ_PROVISION_FAILED", message: "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210" }; - return h4( + return h5( "div", { className: "ddt-card dim-surfaceCard" }, - h4( + h5( "div", { className: "ddt-inlineError dim-inlineError", role: "alert" }, - h4("h3", null, "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), - h4("p", null, error.message), - h4("span", { className: "ddt-errorCode" }, error.code), - h4( + h5("h3", null, "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), + h5("p", null, error.message), + h5("span", { className: "ddt-errorCode" }, error.code), + h5( "div", { className: "ddt-actions dim-viewActions" }, - h4(Button5, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), - h4(Button5, { onClick: onClose, disabled: busy }, "\u5173\u95ED") + h5(Button5, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h5(Button5, { onClick: onClose, disabled: busy }, "\u5173\u95ED") ) ) ); } function RemoveConfirmation3({ account, busy, onConfirm, onCancel }) { - return h4( + return h5( "div", { className: "ddt-confirm dim-confirm", role: "alertdialog" }, - h4("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), - h4("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684\u5E94\u7528\u51ED\u636E\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002\u817E\u8BAF\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), - h4( + h5("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), + h5("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684\u5E94\u7528\u51ED\u636E\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002\u817E\u8BAF\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), + h5( "div", { className: "ddt-actions dim-viewActions" }, - h4(Button5, { onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), - h4(Button5, { kind: "danger", onClick: onConfirm, disabled: busy }, busy ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664\u63A5\u5165") + h5(Button5, { onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), + h5(Button5, { kind: "danger", onClick: onConfirm, disabled: busy }, busy ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664\u63A5\u5165") ) ); } function AccountCard2({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { const tone = account.connected ? "success" : account.state === "error" ? "error" : "warning"; const stateLabel = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : account.state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA"; - return h4( + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); + return h5( "article", { className: "ddt-card dim-botCard", "data-bot-id": account.botId }, - h4( + h5( "div", { className: "ddt-cardBody dim-botCardBody" }, - h4( + h5( "div", { className: "ddt-accountTop dim-botCardTop" }, - h4( + h5( "div", { className: "ddt-accountIdentity dim-botIdentity" }, - h4("div", { className: "ddt-avatar dim-botAvatar dqq-avatar", "aria-hidden": "true" }, h4(QqLogoGlyph, { size: 29 })), - h4( + h5("div", { className: "ddt-avatar dim-botAvatar dqq-avatar", "aria-hidden": "true" }, h5(QqLogoGlyph, { size: 29 })), + h5( "div", { className: "dim-botName" }, - h4("h3", null, account.bot.name), - h4("p", null, account.bot.appIdMasked) + h5("h3", null, account.bot.name), + h5("p", null, account.bot.appIdMasked) ) ), - h4( + h5( "div", { className: "ddt-health dim-botHealth" }, - h4("span", { className: "ddt-dot dim-healthDot", "data-tone": tone }), - h4("span", null, stateLabel) + h5("span", { className: "ddt-dot dim-healthDot", "data-tone": tone }), + h5("span", null, stateLabel) ) ), - h4( + h5( "dl", { className: "ddt-metrics dim-botMetrics" }, - h4("div", { className: "ddt-metric dim-botMetric" }, h4("dt", null, "\u6D88\u606F\u901A\u9053"), h4("dd", null, account.connected ? "WebSocket \u957F\u8FDE\u63A5" : "\u79BB\u7EBF")), - h4("div", { className: "ddt-metric dim-botMetric" }, h4("dt", null, "\u6700\u8FD1\u68C0\u67E5"), h4("dd", null, checkedTime2(account.health.lastCheckedAt))) + h5("div", { className: "ddt-metric dim-botMetric" }, h5("dt", null, "\u6D88\u606F\u901A\u9053"), h5("dd", null, account.connected ? "WebSocket \u957F\u8FDE\u63A5" : "\u79BB\u7EBF")), + h5("div", { className: "ddt-metric dim-botMetric" }, h5("dt", null, "\u6700\u8FD1\u68C0\u67E5"), h5("dd", null, checkedTime2(account.health.lastCheckedAt))) ), - h4( + h5( "div", { className: "ddt-accountFooter dim-cardFooter" }, - h4("div", { className: "ddt-summary dim-cardSummary" }, account.error?.message ?? account.health.summary), - h4( + summary ? h5("div", { className: "ddt-summary dim-cardSummary" }, summary) : null, + h5( "div", { className: "ddt-actions dim-cardActions" }, - h4(Button5, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"), - h4(Button5, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") + h5(Button5, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"), + h5(Button5, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") ) ) ), - removing ? h4(RemoveConfirmation3, { + removing ? h5(RemoveConfirmation3, { account, busy: busy === "delete", onConfirm: onConfirmRemove, @@ -3270,15 +3542,17 @@ function AccountCard2({ account, busy, removing, onReconnect, onRequestRemove, o ); } function QqSettingsTab({ rpcCall }) { - const [model, setModel] = React5.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); - const [provision, setProvision] = React5.useState(null); - const [busy, setBusy] = React5.useState(false); - const [busyByBot, setBusyByBot] = React5.useState({}); - const [removeTarget, setRemoveTarget] = React5.useState(null); - const [now, setNow] = React5.useState(Date.now()); - const mounted = React5.useRef(true); - const addButtonRef = React5.useRef(null); - React5.useEffect(() => { + const [model, setModel] = React6.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); + const [provision, setProvision] = React6.useState(null); + const [busy, setBusy] = React6.useState(false); + const [busyByBot, setBusyByBot] = React6.useState({}); + const [removeTarget, setRemoveTarget] = React6.useState(null); + const [credentialOpen, setCredentialOpen] = React6.useState(false); + const [credentialError, setCredentialError] = React6.useState(null); + const [now, setNow] = React6.useState(Date.now()); + const mounted = React6.useRef(true); + const addButtonRef = React6.useRef(null); + React6.useEffect(() => { const disposeDingtalk = installDingtalkStyles(); const disposeQq = installQqStyles(); mounted.current = true; @@ -3288,11 +3562,11 @@ function QqSettingsTab({ rpcCall }) { disposeDingtalk(); }; }, []); - const invoke = React5.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React6.useCallback(async (endpoint, payload = {}, signal) => { if (typeof rpcCall !== "function") throw new TypeError("QQ \u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); return unwrapRpcResult3(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React5.useCallback(async ({ signal, silent = false, restore = false } = {}) => { + const loadStatus = React6.useCallback(async ({ signal, silent = false, restore = false } = {}) => { if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); try { const snapshot = normalizeSnapshot2(await invoke(QQ_ENDPOINTS.status, {}, signal)); @@ -3310,12 +3584,12 @@ function QqSettingsTab({ rpcCall }) { return void 0; } }, [invoke]); - React5.useEffect(() => { + React6.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restore: true }); return () => controller.abort(); }, [loadStatus]); - React5.useEffect(() => { + React6.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15e3); @@ -3324,12 +3598,14 @@ function QqSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React5.useEffect(() => { + React6.useEffect(() => { if (!provision || !ACTIVE_STATES.has(provision.status)) return void 0; const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); - const startProvisioning = React5.useCallback(async (replace = false) => { + const startProvisioning = React6.useCallback(async (replace = false) => { + setCredentialOpen(false); + setCredentialError(null); setBusy(true); try { if (replace && provision?.attemptId) await invoke(QQ_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); @@ -3345,7 +3621,24 @@ function QqSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, provision?.attemptId]); - const closeProvision = React5.useCallback(async () => { + const bindCredentials = React6.useCallback(async ({ identity, secret }) => { + setBusy(true); + setCredentialError(null); + try { + const snapshot = normalizeSnapshot2(await invoke( + QQ_ENDPOINTS.bindCredentials, + { appId: identity, appSecret: secret } + )); + if (!mounted.current) return; + setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); + setCredentialOpen(false); + } catch (error) { + if (mounted.current) setCredentialError(presentError3(error)); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke]); + const closeProvision = React6.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && ACTIVE_STATES.has(provision.status)) { @@ -3356,7 +3649,7 @@ function QqSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, provision?.attemptId, provision?.status]); - React5.useEffect(() => { + React6.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !ACTIVE_STATES.has(provision.status)) return void 0; const controller = new AbortController(); @@ -3386,7 +3679,7 @@ function QqSettingsTab({ rpcCall }) { window.clearTimeout(timer); }; }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); - const botAction = React5.useCallback(async (account, operation, endpoint, payload) => { + const botAction = React6.useCallback(async (account, operation, endpoint, payload) => { setBusyByBot((current) => ({ ...current, [account.botId]: operation })); try { const snapshot = normalizeSnapshot2(await invoke(endpoint, payload)); @@ -3401,30 +3694,29 @@ function QqSettingsTab({ rpcCall }) { } }, [invoke]); let provisionView = null; - if (provision?.status === "starting") provisionView = h4("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h4("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7 QQ \u4E8C\u7EF4\u7801\u2026"); - else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h4(QrPanel2, { + if (provision?.status === "starting") provisionView = h5("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h5("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7 QQ \u4E8C\u7EF4\u7801\u2026"); + else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h5(QrPanel2, { provision, now, busy, onRefresh: () => void startProvisioning(true), onCancel: () => void closeProvision() }); - else if (provision) provisionView = h4(ProvisionView, { + else if (provision) provisionView = h5(ProvisionView, { provision, busy, onRetry: () => void startProvisioning(true), onClose: () => void closeProvision() }); - const botList = model.bots.length > 0 ? h4( + const botList = model.bots.length > 0 ? h5( "section", { className: "dim-listSection" }, - h4( + h5( "div", { className: "ddt-listHeading dim-listHeading" }, - h4("h3", null, "\u5DF2\u7ED1\u5B9A\u7684 QQ \u673A\u5668\u4EBA"), - h4("span", null, `${model.bots.length} \u4E2A`) + h5("h3", null, "\u5DF2\u7ED1\u5B9A\u7684 QQ \u673A\u5668\u4EBA") ), - h4("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h4("li", { key: account.botId }, h4(AccountCard2, { + h5("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h5("li", { key: account.botId }, h5(AccountCard2, { account, busy: busyByBot[account.botId], removing: removeTarget === account.botId, @@ -3437,15 +3729,41 @@ function QqSettingsTab({ rpcCall }) { } })))) ) : null; - return h4( + const credentialView = credentialOpen ? h5(CredentialBindingPanel, { + channel: "QQ", + identityLabel: "AppID", + identityPlaceholder: "\u586B\u5199 QQ \u5F00\u653E\u5E73\u53F0 AppID", + secretLabel: "AppSecret", + secretPlaceholder: "\u586B\u5199 QQ \u5F00\u653E\u5E73\u53F0 AppSecret", + busy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { + setCredentialOpen(false); + setCredentialError(null); + } + }) : null; + return h5( "section", { className: "ddt-page dqq-page dim-channelPage", "aria-label": "QQ \u8BBE\u7F6E" }, - h4(Heading3, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }), - model.phase === "loading" ? h4(LoadingView3) : model.phase === "error" ? h4("div", { className: "ddt-card dim-surfaceCard" }, h4("div", { className: "ddt-inlineError dim-inlineError" }, h4("h3", null, "\u65E0\u6CD5\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001"), h4("p", null, model.error?.message), h4(Button5, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h4( - React5.Fragment, + h5(Heading3, { + totals: model.totals, + adding: Boolean(provision), + busy, + onAdd: () => void startProvisioning(), + onCredential: () => { + setCredentialOpen((value) => !value); + setCredentialError(null); + }, + credentialOpen, + addButtonRef + }), + model.phase === "loading" ? h5(LoadingView3) : model.phase === "error" ? h5("div", { className: "ddt-card dim-surfaceCard" }, h5("div", { className: "ddt-inlineError dim-inlineError" }, h5("h3", null, "\u65E0\u6CD5\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001"), h5("p", null, model.error?.message), h5(Button5, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h5( + React6.Fragment, null, + credentialView, provisionView, - model.bots.length === 0 && !provision ? h4(EmptyView3, { busy, onStart: () => void startProvisioning() }) : null, + model.bots.length === 0 && !provision && !credentialOpen ? h5(EmptyView3, { busy, onStart: () => void startProvisioning() }) : null, botList ) ); @@ -3458,6 +3776,7 @@ var WECOM_ENDPOINTS = Object.freeze({ beginProvisioning: "provision.begin", pollProvisioning: "provision.poll", cancelProvisioning: "provision.cancel", + bindCredentials: "bot.bind-credentials", reconnectBot: "bot.reconnect", deleteBot: "bot.delete" }); @@ -3558,7 +3877,7 @@ function formatRemaining4(milliseconds) { } // plugin-src/client/channels/wecom/index.js -var React6 = __toESM(require("react"), 1); +var React7 = __toESM(require("react"), 1); // plugin-src/client/channels/wecom/styles.js var WECOM_STYLE_ID = "xmanrui-dsh-im-wecom-settings"; @@ -3582,10 +3901,10 @@ function installWecomStyles() { } // plugin-src/client/channels/wecom/index.js -var h5 = React6.createElement; +var h6 = React7.createElement; var ACTIVE_STATES2 = /* @__PURE__ */ new Set(["pending", "refreshing", "connecting"]); -var Button7 = React6.forwardRef(function Button8({ children, kind = "secondary", className = "", ...props }, ref) { - return h5("button", { +var Button7 = React7.forwardRef(function Button8({ children, kind = "secondary", className = "", ...props }, ref) { + return h6("button", { ...props, ref, type: "button", @@ -3605,68 +3924,81 @@ function checkedTime3(value) { return "\u521A\u521A"; } } -function Heading4({ totals, adding, busy, onAdd, addButtonRef }) { - return h5( +function Heading4({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) { + return h6( "div", { className: "ddt-heading" }, - h5( + h6( "div", { className: "ddt-tools" }, - h5(Button7, { - kind: "primary", - className: "dim-scanButton", - onClick: onAdd, - disabled: adding || busy, - ref: addButtonRef - }, adding ? "\u6B63\u5728\u7ED1\u5B9A" : "\u626B\u7801\u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA"), - totals.configured > 0 ? h5( + h6( + "div", + { className: "dim-bindActions" }, + h6(Button7, { + kind: "primary", + className: "dim-scanButton", + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + "aria-label": "\u626B\u7801\u63A5\u5165\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA" + }, h6(QrActionIcon), adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), + h6(Button7, { + kind: "credential", + className: "dim-credentialButton", + onClick: onCredential, + disabled: adding || busy, + "aria-pressed": credentialOpen, + "aria-label": "\u4F7F\u7528 Bot ID \u548C Secret \u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA" + }, h6(CredentialActionIcon), credentialOpen ? "\u6536\u8D77\u51ED\u636E" : "\u624B\u52A8\u63A5\u5165") + ), + totals.configured > 0 ? h6( "div", { className: "ddt-badge dim-onlineBadge" }, - h5("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) + h6("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) ) : null ) ); } function LoadingView4() { - return h5( + return h6( "div", { className: "ddt-card ddt-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h5("div", { className: "ddt-spinner dim-spinner" }), - h5("span", null, "\u6B63\u5728\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001\u2026") + h6("div", { className: "ddt-spinner dim-spinner" }), + h6("span", null, "\u6B63\u5728\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001\u2026") ); } function EmptyView4({ busy, onStart }) { - return h5( + return h6( "div", { className: "ddt-card dim-surfaceCard" }, - h5( + h6( "div", { className: "ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView" }, - h5( + h6( "div", { className: "dim-emptyCopy" }, - h5( + h6( "div", { className: "ddt-stateLabel dim-stateLabel" }, - h5("span", { className: "ddt-dot dim-stateDot" }), - h5("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA") + h6("span", { className: "ddt-dot dim-stateDot" }), + h6("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA") ), - h5("h3", null, "\u4F7F\u7528\u4F01\u4E1A\u5FAE\u4FE1 App \u626B\u7801\u521B\u5EFA\u667A\u80FD\u673A\u5668\u4EBA"), - h5("p", null, "\u626B\u7801\u7531\u817E\u8BAF\u5B98\u65B9\u9875\u9762\u5B8C\u6210\uFF0C\u4E0D\u9700\u8981\u624B\u52A8\u586B\u5199 Bot ID \u6216 Secret\u3002\u521B\u5EFA\u6210\u529F\u540E\uFF0C\u673A\u5668\u4EBA\u4F1A\u81EA\u52A8\u8FDE\u63A5 DeepSeek Harness\u3002"), - h5( + h6("h3", null, "\u4F7F\u7528\u4F01\u4E1A\u5FAE\u4FE1 App \u626B\u7801\u521B\u5EFA\u667A\u80FD\u673A\u5668\u4EBA"), + h6("p", null, "\u626B\u7801\u7531\u817E\u8BAF\u5B98\u65B9\u9875\u9762\u5B8C\u6210\uFF0C\u4E0D\u9700\u8981\u624B\u52A8\u586B\u5199 Bot ID \u6216 Secret\u3002\u521B\u5EFA\u6210\u529F\u540E\uFF0C\u673A\u5668\u4EBA\u4F1A\u81EA\u52A8\u8FDE\u63A5 DeepSeek Harness\u3002"), + h6( "div", { className: "ddt-actions dim-viewActions" }, - h5( + h6( Button7, { kind: "primary", onClick: onStart, disabled: busy }, busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801" ) ) ), - h5( + h6( "div", { className: "ddt-brandMark dim-emptyBrand dwecom-brand", "aria-hidden": "true" }, - h5(WecomLogoGlyph, { size: 64 }) + h6(WecomLogoGlyph, { size: 64 }) ) ) ); @@ -3677,59 +4009,59 @@ function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { const duration = Math.max(1, provision.durationMs ?? 5 * 6e4); const progress = Math.round(Math.min(1, remaining / duration) * 100); const refreshing = provision.status === "refreshing"; - return h5( + return h6( "div", { className: "ddt-card dim-surfaceCard" }, - h5( + h6( "div", { className: "ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout" }, - h5( + h6( "div", { className: "ddt-qrColumn dim-qrColumn" }, - h5( + h6( "div", { className: "ddt-qrFrame dim-qrFrame" }, - source ? h5("img", { src: source, alt: "\u7528\u4E8E\u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801" }) : h5( + source ? h6("img", { src: source, alt: "\u7528\u4E8E\u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801" }) : h6( "div", { className: "ddt-qrFallback dim-qrFallback" }, refreshing ? "\u4E8C\u7EF4\u7801\u6B63\u5728\u81EA\u52A8\u5237\u65B0\u2026" : "\u4E8C\u7EF4\u7801\u56FE\u7247\u6B63\u5728\u751F\u6210\u2026" ) ), - h5( + h6( "div", { className: "ddt-countdown dim-countdown" }, - h5( + h6( "div", { className: "ddt-countdownTop dim-countdownTop" }, - h5("span", null, "\u5F53\u524D\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), - h5("strong", null, refreshing ? "--:--" : formatRemaining4(remaining)) + h6("span", null, "\u5F53\u524D\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), + h6("strong", null, refreshing ? "--:--" : formatRemaining4(remaining)) ), - h5("div", { className: "ddt-progress dim-progress", style: { "--ddt-progress": `${progress}%` } }, h5("span")) + h6("div", { className: "ddt-progress dim-progress", style: { "--ddt-progress": `${progress}%` } }, h6("span")) ) ), - h5( + h6( "div", { className: "ddt-qrCopy dim-qrCopy" }, - h5( + h6( "div", { className: "ddt-stateLabel dim-stateLabel" }, - h5("span", { className: "ddt-dot dim-stateDot", "data-tone": "warning" }), - h5("span", null, refreshing ? "\u6B63\u5728\u5237\u65B0\u4E8C\u7EF4\u7801" : "\u7B49\u5F85\u4F01\u4E1A\u5FAE\u4FE1 App \u626B\u7801") + h6("span", { className: "ddt-dot dim-stateDot", "data-tone": "warning" }), + h6("span", null, refreshing ? "\u6B63\u5728\u5237\u65B0\u4E8C\u7EF4\u7801" : "\u7B49\u5F85\u4F01\u4E1A\u5FAE\u4FE1 App \u626B\u7801") ), - h5("h3", null, "\u4F7F\u7528\u4F01\u4E1A\u5FAE\u4FE1 App \u5B8C\u6210\u667A\u80FD\u673A\u5668\u4EBA\u6388\u6743"), - h5("p", null, "\u4F01\u4E1A\u5FAE\u4FE1\u5B98\u65B9\u9875\u9762\u4F1A\u521B\u5EFA\u4E00\u4E2A\u667A\u80FD\u673A\u5668\u4EBA\uFF0C\u5E76\u628A\u8FDE\u63A5\u51ED\u636E\u5B89\u5168\u4EA4\u7ED9\u672C\u673A Harness Host\u3002"), - h5( + h6("h3", null, "\u4F7F\u7528\u4F01\u4E1A\u5FAE\u4FE1 App \u5B8C\u6210\u667A\u80FD\u673A\u5668\u4EBA\u6388\u6743"), + h6("p", null, "\u4F01\u4E1A\u5FAE\u4FE1\u5B98\u65B9\u9875\u9762\u4F1A\u521B\u5EFA\u4E00\u4E2A\u667A\u80FD\u673A\u5668\u4EBA\uFF0C\u5E76\u628A\u8FDE\u63A5\u51ED\u636E\u5B89\u5168\u4EA4\u7ED9\u672C\u673A Harness Host\u3002"), + h6( "ol", { className: "ddt-steps dim-steps" }, - h5("li", null, "\u6253\u5F00\u4F01\u4E1A\u5FAE\u4FE1 App\uFF0C\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), - h5("li", null, "\u5728\u817E\u8BAF\u6388\u6743\u9875\u9762\u786E\u8BA4\u521B\u5EFA\u667A\u80FD\u673A\u5668\u4EBA"), - h5("li", null, "\u8FD4\u56DE\u8FD9\u91CC\u7B49\u5F85\u8FDE\u63A5\u5B8C\u6210") + h6("li", null, "\u6253\u5F00\u4F01\u4E1A\u5FAE\u4FE1 App\uFF0C\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), + h6("li", null, "\u5728\u817E\u8BAF\u6388\u6743\u9875\u9762\u786E\u8BA4\u521B\u5EFA\u667A\u80FD\u673A\u5668\u4EBA"), + h6("li", null, "\u8FD4\u56DE\u8FD9\u91CC\u7B49\u5F85\u8FDE\u63A5\u5B8C\u6210") ), - h5( + h6( "div", { className: "ddt-actions dim-viewActions" }, - h5(Button7, { onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), - h5(Button7, { kind: "quiet", onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + h6(Button7, { onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h6(Button7, { kind: "quiet", onClick: onCancel, disabled: busy }, "\u53D6\u6D88") ) ) ) @@ -3737,96 +4069,97 @@ function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { } function ProvisionView2({ provision, busy, onRetry, onClose }) { if (provision.status === "connecting") { - return h5( + return h6( "div", { className: "ddt-card ddt-loading dim-surfaceCard dim-specialView", "aria-busy": "true" }, - h5("div", { className: "ddt-spinner dim-spinner" }), - h5("h3", null, "\u4F01\u4E1A\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u6B63\u5728\u8FDE\u63A5\u673A\u5668\u4EBA"), - h5("p", null, "\u51ED\u636E\u6B63\u5728\u5199\u5165\u672C\u673A\uFF0C\u5E76\u542F\u52A8\u4F01\u4E1A\u5FAE\u4FE1 WebSocket \u6D88\u606F\u8FDE\u63A5\u3002") + h6("div", { className: "ddt-spinner dim-spinner" }), + h6("h3", null, "\u4F01\u4E1A\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u6B63\u5728\u8FDE\u63A5\u673A\u5668\u4EBA"), + h6("p", null, "\u51ED\u636E\u6B63\u5728\u5199\u5165\u672C\u673A\uFF0C\u5E76\u542F\u52A8\u4F01\u4E1A\u5FAE\u4FE1 WebSocket \u6D88\u606F\u8FDE\u63A5\u3002") ); } const error = provision.error ?? { code: "WECOM_PROVISION_FAILED", message: "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210" }; - return h5( + return h6( "div", { className: "ddt-card dim-surfaceCard" }, - h5( + h6( "div", { className: "ddt-inlineError dim-inlineError", role: "alert" }, - h5("h3", null, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), - h5("p", null, error.message), - h5("span", { className: "ddt-errorCode" }, error.code), - h5( + h6("h3", null, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), + h6("p", null, error.message), + h6("span", { className: "ddt-errorCode" }, error.code), + h6( "div", { className: "ddt-actions dim-viewActions" }, - h5(Button7, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), - h5(Button7, { onClick: onClose, disabled: busy }, "\u5173\u95ED") + h6(Button7, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h6(Button7, { onClick: onClose, disabled: busy }, "\u5173\u95ED") ) ) ); } function RemoveConfirmation4({ account, busy, onConfirm, onCancel }) { - return h5( + return h6( "div", { className: "ddt-confirm dim-confirm", role: "alertdialog" }, - h5("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), - h5("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684\u5E94\u7528\u51ED\u636E\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002\u4F01\u4E1A\u5FAE\u4FE1\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), - h5( + h6("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), + h6("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684\u5E94\u7528\u51ED\u636E\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002\u4F01\u4E1A\u5FAE\u4FE1\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), + h6( "div", { className: "ddt-actions dim-viewActions" }, - h5(Button7, { onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), - h5(Button7, { kind: "danger", onClick: onConfirm, disabled: busy }, busy ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664\u63A5\u5165") + h6(Button7, { onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), + h6(Button7, { kind: "danger", onClick: onConfirm, disabled: busy }, busy ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664\u63A5\u5165") ) ); } function AccountCard3({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { const tone = account.connected ? "success" : account.state === "error" ? "error" : "warning"; const stateLabel = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : account.state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA"; - return h5( + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); + return h6( "article", { className: "ddt-card dim-botCard", "data-bot-id": account.botId }, - h5( + h6( "div", { className: "ddt-cardBody dim-botCardBody" }, - h5( + h6( "div", { className: "ddt-accountTop dim-botCardTop" }, - h5( + h6( "div", { className: "ddt-accountIdentity dim-botIdentity" }, - h5("div", { className: "ddt-avatar dim-botAvatar dwecom-avatar", "aria-hidden": "true" }, h5(WecomLogoGlyph, { size: 29 })), - h5( + h6("div", { className: "ddt-avatar dim-botAvatar dwecom-avatar", "aria-hidden": "true" }, h6(WecomLogoGlyph, { size: 29 })), + h6( "div", { className: "dim-botName" }, - h5("h3", null, account.bot.name), - h5("p", null, account.bot.appIdMasked) + h6("h3", null, account.bot.name), + h6("p", null, account.bot.appIdMasked) ) ), - h5( + h6( "div", { className: "ddt-health dim-botHealth" }, - h5("span", { className: "ddt-dot dim-healthDot", "data-tone": tone }), - h5("span", null, stateLabel) + h6("span", { className: "ddt-dot dim-healthDot", "data-tone": tone }), + h6("span", null, stateLabel) ) ), - h5( + h6( "dl", { className: "ddt-metrics dim-botMetrics" }, - h5("div", { className: "ddt-metric dim-botMetric" }, h5("dt", null, "\u6D88\u606F\u901A\u9053"), h5("dd", null, account.connected ? "WebSocket \u957F\u8FDE\u63A5" : "\u79BB\u7EBF")), - h5("div", { className: "ddt-metric dim-botMetric" }, h5("dt", null, "\u6700\u8FD1\u68C0\u67E5"), h5("dd", null, checkedTime3(account.health.lastCheckedAt))) + h6("div", { className: "ddt-metric dim-botMetric" }, h6("dt", null, "\u6D88\u606F\u901A\u9053"), h6("dd", null, account.connected ? "WebSocket \u957F\u8FDE\u63A5" : "\u79BB\u7EBF")), + h6("div", { className: "ddt-metric dim-botMetric" }, h6("dt", null, "\u6700\u8FD1\u68C0\u67E5"), h6("dd", null, checkedTime3(account.health.lastCheckedAt))) ), - h5( + h6( "div", { className: "ddt-accountFooter dim-cardFooter" }, - h5("div", { className: "ddt-summary dim-cardSummary" }, account.error?.message ?? account.health.summary), - h5( + summary ? h6("div", { className: "ddt-summary dim-cardSummary" }, summary) : null, + h6( "div", { className: "ddt-actions dim-cardActions" }, - h5(Button7, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"), - h5(Button7, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") + h6(Button7, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"), + h6(Button7, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") ) ) ), - removing ? h5(RemoveConfirmation4, { + removing ? h6(RemoveConfirmation4, { account, busy: busy === "delete", onConfirm: onConfirmRemove, @@ -3835,15 +4168,17 @@ function AccountCard3({ account, busy, removing, onReconnect, onRequestRemove, o ); } function WecomSettingsTab({ rpcCall }) { - const [model, setModel] = React6.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); - const [provision, setProvision] = React6.useState(null); - const [busy, setBusy] = React6.useState(false); - const [busyByBot, setBusyByBot] = React6.useState({}); - const [removeTarget, setRemoveTarget] = React6.useState(null); - const [now, setNow] = React6.useState(Date.now()); - const mounted = React6.useRef(true); - const addButtonRef = React6.useRef(null); - React6.useEffect(() => { + const [model, setModel] = React7.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); + const [provision, setProvision] = React7.useState(null); + const [busy, setBusy] = React7.useState(false); + const [busyByBot, setBusyByBot] = React7.useState({}); + const [removeTarget, setRemoveTarget] = React7.useState(null); + const [credentialOpen, setCredentialOpen] = React7.useState(false); + const [credentialError, setCredentialError] = React7.useState(null); + const [now, setNow] = React7.useState(Date.now()); + const mounted = React7.useRef(true); + const addButtonRef = React7.useRef(null); + React7.useEffect(() => { const disposeDingtalk = installDingtalkStyles(); const disposeWecom = installWecomStyles(); mounted.current = true; @@ -3853,11 +4188,11 @@ function WecomSettingsTab({ rpcCall }) { disposeDingtalk(); }; }, []); - const invoke = React6.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React7.useCallback(async (endpoint, payload = {}, signal) => { if (typeof rpcCall !== "function") throw new TypeError("\u4F01\u4E1A\u5FAE\u4FE1\u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); return unwrapRpcResult4(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React6.useCallback(async ({ signal, silent = false, restore = false } = {}) => { + const loadStatus = React7.useCallback(async ({ signal, silent = false, restore = false } = {}) => { if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); try { const snapshot = normalizeSnapshot3(await invoke(WECOM_ENDPOINTS.status, {}, signal)); @@ -3875,12 +4210,12 @@ function WecomSettingsTab({ rpcCall }) { return void 0; } }, [invoke]); - React6.useEffect(() => { + React7.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restore: true }); return () => controller.abort(); }, [loadStatus]); - React6.useEffect(() => { + React7.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15e3); @@ -3889,12 +4224,14 @@ function WecomSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React6.useEffect(() => { + React7.useEffect(() => { if (!provision || !ACTIVE_STATES2.has(provision.status)) return void 0; const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); - const startProvisioning = React6.useCallback(async (replace = false) => { + const startProvisioning = React7.useCallback(async (replace = false) => { + setCredentialOpen(false); + setCredentialError(null); setBusy(true); try { if (replace && provision?.attemptId) await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); @@ -3910,7 +4247,24 @@ function WecomSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, provision?.attemptId]); - const closeProvision = React6.useCallback(async () => { + const bindCredentials = React7.useCallback(async ({ identity, secret }) => { + setBusy(true); + setCredentialError(null); + try { + const snapshot = normalizeSnapshot3(await invoke( + WECOM_ENDPOINTS.bindCredentials, + { botId: identity, secret } + )); + if (!mounted.current) return; + setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); + setCredentialOpen(false); + } catch (error) { + if (mounted.current) setCredentialError(presentError4(error)); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke]); + const closeProvision = React7.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && ACTIVE_STATES2.has(provision.status)) { @@ -3921,7 +4275,7 @@ function WecomSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, provision?.attemptId, provision?.status]); - React6.useEffect(() => { + React7.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !ACTIVE_STATES2.has(provision.status)) return void 0; const controller = new AbortController(); @@ -3951,7 +4305,7 @@ function WecomSettingsTab({ rpcCall }) { window.clearTimeout(timer); }; }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); - const botAction = React6.useCallback(async (account, operation, endpoint, payload) => { + const botAction = React7.useCallback(async (account, operation, endpoint, payload) => { setBusyByBot((current) => ({ ...current, [account.botId]: operation })); try { const snapshot = normalizeSnapshot3(await invoke(endpoint, payload)); @@ -3966,30 +4320,29 @@ function WecomSettingsTab({ rpcCall }) { } }, [invoke]); let provisionView = null; - if (provision?.status === "starting") provisionView = h5("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h5("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u2026"); - else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h5(QrPanel3, { + if (provision?.status === "starting") provisionView = h6("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h6("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u2026"); + else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h6(QrPanel3, { provision, now, busy, onRefresh: () => void startProvisioning(true), onCancel: () => void closeProvision() }); - else if (provision) provisionView = h5(ProvisionView2, { + else if (provision) provisionView = h6(ProvisionView2, { provision, busy, onRetry: () => void startProvisioning(true), onClose: () => void closeProvision() }); - const botList = model.bots.length > 0 ? h5( + const botList = model.bots.length > 0 ? h6( "section", { className: "dim-listSection" }, - h5( + h6( "div", { className: "ddt-listHeading dim-listHeading" }, - h5("h3", null, "\u5DF2\u7ED1\u5B9A\u7684\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA"), - h5("span", null, `${model.bots.length} \u4E2A`) + h6("h3", null, "\u5DF2\u7ED1\u5B9A\u7684\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA") ), - h5("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h5("li", { key: account.botId }, h5(AccountCard3, { + h6("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h6("li", { key: account.botId }, h6(AccountCard3, { account, busy: busyByBot[account.botId], removing: removeTarget === account.botId, @@ -4002,22 +4355,48 @@ function WecomSettingsTab({ rpcCall }) { } })))) ) : null; - return h5( + const credentialView = credentialOpen ? h6(CredentialBindingPanel, { + channel: "\u4F01\u4E1A\u5FAE\u4FE1", + identityLabel: "Bot ID", + identityPlaceholder: "\u586B\u5199\u4F01\u4E1A\u5FAE\u4FE1\u667A\u80FD\u673A\u5668\u4EBA Bot ID", + secretLabel: "Secret", + secretPlaceholder: "\u586B\u5199\u4F01\u4E1A\u5FAE\u4FE1\u667A\u80FD\u673A\u5668\u4EBA Secret", + busy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { + setCredentialOpen(false); + setCredentialError(null); + } + }) : null; + return h6( "section", { className: "ddt-page dwecom-page dim-channelPage", "aria-label": "\u4F01\u4E1A\u5FAE\u4FE1\u8BBE\u7F6E" }, - h5(Heading4, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }), - model.phase === "loading" ? h5(LoadingView4) : model.phase === "error" ? h5("div", { className: "ddt-card dim-surfaceCard" }, h5("div", { className: "ddt-inlineError dim-inlineError" }, h5("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001"), h5("p", null, model.error?.message), h5(Button7, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h5( - React6.Fragment, + h6(Heading4, { + totals: model.totals, + adding: Boolean(provision), + busy, + onAdd: () => void startProvisioning(), + onCredential: () => { + setCredentialOpen((value) => !value); + setCredentialError(null); + }, + credentialOpen, + addButtonRef + }), + model.phase === "loading" ? h6(LoadingView4) : model.phase === "error" ? h6("div", { className: "ddt-card dim-surfaceCard" }, h6("div", { className: "ddt-inlineError dim-inlineError" }, h6("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001"), h6("p", null, model.error?.message), h6(Button7, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h6( + React7.Fragment, null, + credentialView, provisionView, - model.bots.length === 0 && !provision ? h5(EmptyView4, { busy, onStart: () => void startProvisioning() }) : null, + model.bots.length === 0 && !provision && !credentialOpen ? h6(EmptyView4, { busy, onStart: () => void startProvisioning() }) : null, botList ) ); } // plugin-src/client/channels/weixin/index.js -var React7 = __toESM(require("react"), 1); +var React8 = __toESM(require("react"), 1); // plugin-src/client/channels/weixin/api.js var WEIXIN_RPC_CHANNEL = "/weixin"; @@ -4229,7 +4608,6 @@ var CSS5 = String.raw` .dxw-errorCode { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11px; opacity: .8; } .dxw-listHeading { display: flex; justify-content: space-between; align-items: center; margin: 2px 0 9px; } .dxw-listHeading h3 { margin: 0; font-size: 14px; } -.dxw-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; } .dxw-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; } .dxw-accountTop { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } .dxw-accountIdentity { display: flex; align-items: center; gap: 12px; min-width: 0; } @@ -4281,9 +4659,9 @@ function installWeixinStyles() { } // plugin-src/client/channels/weixin/index.js -var h6 = React7.createElement; -var Button9 = React7.forwardRef(function Button10({ children, kind = "secondary", className = "", ...props }, ref) { - return h6("button", { +var h7 = React8.createElement; +var Button9 = React8.forwardRef(function Button10({ children, kind = "secondary", className = "", ...props }, ref) { + return h7("button", { ...props, ref, type: "button", @@ -4292,149 +4670,150 @@ var Button9 = React7.forwardRef(function Button10({ children, kind = "secondary" }, children); }); function Heading5({ totals, adding, busy, onAdd, addButtonRef }) { - return h6( + return h7( "div", { className: "dxw-heading" }, - h6( + h7( "div", { className: "dxw-tools" }, - h6(Button9, { + h7(Button9, { kind: "primary", className: "dim-scanButton", onClick: onAdd, disabled: adding || busy, - ref: addButtonRef - }, adding ? "\u6B63\u5728\u7ED1\u5B9A" : "\u626B\u7801\u7ED1\u5B9A\u5FAE\u4FE1"), - totals.configured > 0 ? h6( + ref: addButtonRef, + "aria-label": "\u626B\u7801\u63A5\u5165\u5FAE\u4FE1\u673A\u5668\u4EBA" + }, h7(QrActionIcon), adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), + totals.configured > 0 ? h7( "div", { className: "dxw-badge dim-onlineBadge" }, - h6("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) + h7("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) ) : null ) ); } function LoadingView5() { - return h6( + return h7( "div", { className: "dxw-card dxw-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h6("div", { className: "dxw-spinner dim-spinner" }), - h6("span", null, "\u6B63\u5728\u8BFB\u53D6\u5FAE\u4FE1\u8FDE\u63A5\u72B6\u6001\u2026") + h7("div", { className: "dxw-spinner dim-spinner" }), + h7("span", null, "\u6B63\u5728\u8BFB\u53D6\u5FAE\u4FE1\u8FDE\u63A5\u72B6\u6001\u2026") ); } function EmptyView5({ onStart, busy }) { - return h6( + return h7( "div", { className: "dxw-card dim-surfaceCard" }, - h6( + h7( "div", { className: "dxw-cardBody dxw-empty dim-surfaceBody dim-emptyView" }, - h6( + h7( "div", { className: "dim-emptyCopy" }, - h6( + h7( "div", { className: "dxw-stateLabel dim-stateLabel" }, - h6("span", { className: "dxw-dot dim-stateDot" }), - h6("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u5FAE\u4FE1") + h7("span", { className: "dxw-dot dim-stateDot" }), + h7("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u5FAE\u4FE1") ), - h6("h3", null, "\u626B\u4E00\u6B21\u7801\uFF0C\u5C31\u80FD\u5728\u5FAE\u4FE1\u91CC\u4F7F\u7528 Harness"), - h6("p", null, "\u4E8C\u7EF4\u7801\u7531\u817E\u8BAF\u5FAE\u4FE1 iLink \u670D\u52A1\u7B7E\u53D1\u3002\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u5E76\u786E\u8BA4\u540E\uFF0C\u8D26\u53F7\u51ED\u636E\u4F1A\u76F4\u63A5\u5199\u5165 Harness Host\uFF0C\u6D4F\u89C8\u5668\u4E0D\u4F1A\u6536\u5230 bot_token\u3002"), - h6( + h7("h3", null, "\u626B\u4E00\u6B21\u7801\uFF0C\u5C31\u80FD\u5728\u5FAE\u4FE1\u91CC\u4F7F\u7528 Harness"), + h7("p", null, "\u4E8C\u7EF4\u7801\u7531\u817E\u8BAF\u5FAE\u4FE1 iLink \u670D\u52A1\u7B7E\u53D1\u3002\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u5E76\u786E\u8BA4\u540E\uFF0C\u8D26\u53F7\u51ED\u636E\u4F1A\u76F4\u63A5\u5199\u5165 Harness Host\uFF0C\u6D4F\u89C8\u5668\u4E0D\u4F1A\u6536\u5230 bot_token\u3002"), + h7( "div", { className: "dxw-actions dim-viewActions" }, - h6( + h7( Button9, { kind: "primary", onClick: onStart, disabled: busy }, busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210\u5FAE\u4FE1\u4E8C\u7EF4\u7801" ) ) ), - h6("div", { className: "dxw-logo dim-emptyBrand", "aria-hidden": "true" }, h6(WeixinLogoGlyph, { size: 64 })) + h7("div", { className: "dxw-logo dim-emptyBrand", "aria-hidden": "true" }, h7(WeixinLogoGlyph, { size: 64 })) ) ); } function QrPanel4({ provision, now, busy, onRefresh, onCancel }) { - const [imageFailed, setImageFailed] = React7.useState(false); + const [imageFailed, setImageFailed] = React8.useState(false); const source = safeQrSource5(provision.qrCodeDataUrl); const href = safeVerificationUrl(provision.verificationUrl); const remaining = Math.max(0, provision.expiresAt - now); const expired = remaining === 0 || provision.status === "expired"; const duration = Math.max(1, provision.durationMs ?? 5 * 6e4); const progress = Math.round(Math.min(1, remaining / duration) * 100); - React7.useEffect(() => setImageFailed(false), [source]); - return h6( + React8.useEffect(() => setImageFailed(false), [source]); + return h7( "div", { className: "dxw-card dim-surfaceCard" }, - h6( + h7( "div", { className: "dxw-cardBody dxw-qrLayout dim-surfaceBody dim-qrLayout" }, - h6( + h7( "div", { className: "dxw-qrColumn dim-qrColumn" }, - h6( + h7( "div", { className: "dxw-qrFrame dim-qrFrame" }, - source && !imageFailed ? h6("img", { + source && !imageFailed ? h7("img", { src: source, alt: "\u7528\u4E8E\u628A\u5FAE\u4FE1\u673A\u5668\u4EBA\u7ED1\u5B9A\u5230 DeepSeek Harness \u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801", onError: () => setImageFailed(true) - }) : h6("div", { className: "dxw-qrFallback dim-qrFallback" }, "\u4E8C\u7EF4\u7801\u56FE\u7247\u672A\u5C31\u7EEA\uFF0C\u8BF7\u4F7F\u7528\u5907\u7528\u94FE\u63A5\u3002"), - expired ? h6("div", { className: "dxw-expired dim-qrExpired" }, "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\n\u8BF7\u91CD\u65B0\u751F\u6210") : null + }) : h7("div", { className: "dxw-qrFallback dim-qrFallback" }, "\u4E8C\u7EF4\u7801\u56FE\u7247\u672A\u5C31\u7EEA\uFF0C\u8BF7\u4F7F\u7528\u5907\u7528\u94FE\u63A5\u3002"), + expired ? h7("div", { className: "dxw-expired dim-qrExpired" }, "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\n\u8BF7\u91CD\u65B0\u751F\u6210") : null ), - h6( + h7( "div", { className: "dxw-countdown dim-countdown" }, - h6("div", { className: "dim-countdownTop" }, h6("span", null, "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), h6("strong", null, formatRemaining5(remaining))), - h6( + h7("div", { className: "dim-countdownTop" }, h7("span", null, "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), h7("strong", null, formatRemaining5(remaining))), + h7( "div", { className: "dxw-progress dim-progress", "aria-hidden": "true" }, - h6("span", { style: { "--dxw-progress": `${progress}%` } }) + h7("span", { style: { "--dxw-progress": `${progress}%` } }) ) ) ), - h6( + h7( "div", { className: "dxw-qrCopy dim-qrCopy" }, - h6( + h7( "div", { className: "dxw-stateLabel dim-stateLabel" }, - h6("span", { className: "dxw-dot dim-stateDot", "data-tone": provision.status === "scanned" ? "success" : "warning" }), - h6("span", null, provision.status === "scanned" ? "\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u4E0A\u786E\u8BA4" : "\u7B49\u5F85\u5FAE\u4FE1\u626B\u7801") + h7("span", { className: "dxw-dot dim-stateDot", "data-tone": provision.status === "scanned" ? "success" : "warning" }), + h7("span", null, provision.status === "scanned" ? "\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u4E0A\u786E\u8BA4" : "\u7B49\u5F85\u5FAE\u4FE1\u626B\u7801") ), - h6("h3", null, expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : "\u4F7F\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u4E8C\u7EF4\u7801"), - h6("p", null, "\u8BF7\u5728\u624B\u673A\u4E0A\u6838\u5BF9\u5E76\u786E\u8BA4\u6388\u6743\u3002\u90E8\u5206\u8D26\u53F7\u4F1A\u989D\u5916\u663E\u793A\u4E00\u4E2A\u914D\u5BF9\u6570\u5B57\uFF0C\u9875\u9762\u4F1A\u5728\u9700\u8981\u65F6\u63D0\u793A\u8F93\u5165\u3002"), - h6( + h7("h3", null, expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : "\u4F7F\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u4E8C\u7EF4\u7801"), + h7("p", null, "\u8BF7\u5728\u624B\u673A\u4E0A\u6838\u5BF9\u5E76\u786E\u8BA4\u6388\u6743\u3002\u90E8\u5206\u8D26\u53F7\u4F1A\u989D\u5916\u663E\u793A\u4E00\u4E2A\u914D\u5BF9\u6570\u5B57\uFF0C\u9875\u9762\u4F1A\u5728\u9700\u8981\u65F6\u63D0\u793A\u8F93\u5165\u3002"), + h7( "ol", { className: "dxw-steps dim-steps" }, - h6("li", null, "\u6253\u5F00\u624B\u673A\u5FAE\u4FE1\u5E76\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), - h6("li", null, "\u5728\u5FAE\u4FE1\u4E2D\u786E\u8BA4\u8FDE\u63A5\u8BE5\u673A\u5668\u4EBA"), - h6("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u6D88\u606F\u957F\u8F6E\u8BE2\u53D8\u4E3A\u5728\u7EBF") + h7("li", null, "\u6253\u5F00\u624B\u673A\u5FAE\u4FE1\u5E76\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), + h7("li", null, "\u5728\u5FAE\u4FE1\u4E2D\u786E\u8BA4\u8FDE\u63A5\u8BE5\u673A\u5668\u4EBA"), + h7("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u6D88\u606F\u957F\u8F6E\u8BE2\u53D8\u4E3A\u5728\u7EBF") ), - h6( + h7( "div", { className: "dxw-actions dim-viewActions" }, - expired ? h6(Button9, { kind: "primary", onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801") : null, - href ? h6("a", { + expired ? h7(Button9, { kind: "primary", onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801") : null, + href ? h7("a", { className: "dxw-button", href, target: "_blank", rel: "noopener noreferrer" }, "\u6253\u5F00\u5907\u7528\u94FE\u63A5") : null, - !expired ? h6(Button9, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, - h6(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + !expired ? h7(Button9, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, + h7(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") ) ) ) ); } function VerificationPanel({ provision, busy, onSubmit, onCancel }) { - const [code, setCode] = React7.useState(""); + const [code, setCode] = React8.useState(""); const valid = /^\d{4,8}$/.test(code); - React7.useEffect(() => setCode(""), [provision.attemptId]); - return h6( + React8.useEffect(() => setCode(""), [provision.attemptId]); + return h7( "div", { className: "dxw-card dim-surfaceCard" }, - h6( + h7( "form", { className: "dxw-verify dim-specialView", @@ -4443,18 +4822,18 @@ function VerificationPanel({ provision, busy, onSubmit, onCancel }) { if (valid && !busy) onSubmit(code); } }, - h6( + h7( "div", { className: "dxw-stateLabel" }, - h6("span", { className: "dxw-dot", "data-tone": "warning" }), - h6("span", null, "\u9700\u8981\u914D\u5BF9\u7801") + h7("span", { className: "dxw-dot", "data-tone": "warning" }), + h7("span", null, "\u9700\u8981\u914D\u5BF9\u7801") ), - h6("h3", null, "\u8F93\u5165\u624B\u673A\u5FAE\u4FE1\u663E\u793A\u7684\u6570\u5B57"), - h6("p", null, "\u8FD9\u662F\u5FAE\u4FE1\u9644\u52A0\u7684\u5B89\u5168\u786E\u8BA4\u6B65\u9AA4\u3002\u914D\u5BF9\u7801\u53EA\u7528\u4E8E\u672C\u6B21\u626B\u7801\u8F6E\u8BE2\uFF0C\u4E0D\u4F1A\u5199\u5165\u914D\u7F6E\u6216\u65E5\u5FD7\u3002"), - h6( + h7("h3", null, "\u8F93\u5165\u624B\u673A\u5FAE\u4FE1\u663E\u793A\u7684\u6570\u5B57"), + h7("p", null, "\u8FD9\u662F\u5FAE\u4FE1\u9644\u52A0\u7684\u5B89\u5168\u786E\u8BA4\u6B65\u9AA4\u3002\u914D\u5BF9\u7801\u53EA\u7528\u4E8E\u672C\u6B21\u626B\u7801\u8F6E\u8BE2\uFF0C\u4E0D\u4F1A\u5199\u5165\u914D\u7F6E\u6216\u65E5\u5FD7\u3002"), + h7( "div", { className: "dxw-codeRow" }, - h6("input", { + h7("input", { className: "dxw-input", value: code, inputMode: "numeric", @@ -4464,47 +4843,47 @@ function VerificationPanel({ provision, busy, onSubmit, onCancel }) { onChange: (event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 8)), autoFocus: true }), - h6("button", { + h7("button", { type: "submit", className: "dxw-button", "data-kind": "primary", disabled: !valid || busy }, busy ? "\u6B63\u5728\u9A8C\u8BC1\u2026" : "\u7EE7\u7EED\u8FDE\u63A5") ), - h6(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u7ED1\u5B9A") + h7(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u7ED1\u5B9A") ) ); } function ProgressPanel2({ scanned, onCancel, busy }) { - return h6( + return h7( "div", { className: "dxw-card dxw-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h6("div", { className: "dxw-spinner dim-spinner" }), - h6("h3", null, scanned ? "\u5FAE\u4FE1\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u542F\u52A8\u6D88\u606F\u8FDE\u63A5" : "\u6B63\u5728\u51C6\u5907\u5FAE\u4FE1\u4E8C\u7EF4\u7801"), - h6("p", null, scanned ? "\u6B63\u5728\u4FDD\u5B58\u51ED\u636E\u5E76\u9A8C\u8BC1 Harness \u4E0E\u5FAE\u4FE1\u957F\u8F6E\u8BE2\u3002" : "\u6B63\u5728\u8054\u7CFB\u817E\u8BAF\u5FAE\u4FE1 iLink \u670D\u52A1\u3002"), - onCancel ? h6( + h7("div", { className: "dxw-spinner dim-spinner" }), + h7("h3", null, scanned ? "\u5FAE\u4FE1\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u542F\u52A8\u6D88\u606F\u8FDE\u63A5" : "\u6B63\u5728\u51C6\u5907\u5FAE\u4FE1\u4E8C\u7EF4\u7801"), + h7("p", null, scanned ? "\u6B63\u5728\u4FDD\u5B58\u51ED\u636E\u5E76\u9A8C\u8BC1 Harness \u4E0E\u5FAE\u4FE1\u957F\u8F6E\u8BE2\u3002" : "\u6B63\u5728\u8054\u7CFB\u817E\u8BAF\u5FAE\u4FE1 iLink \u670D\u52A1\u3002"), + onCancel ? h7( "div", { className: "dxw-actions dim-viewActions", style: { justifyContent: "center", marginTop: 14 } }, - h6(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + h7(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") ) : null ); } function ProvisionError3({ provision, busy, onRetry, onClose }) { const error = provision.error ?? { code: "WEIXIN_PROVISION_FAILED", message: "\u5FAE\u4FE1\u7ED1\u5B9A\u6CA1\u6709\u5B8C\u6210" }; - return h6( + return h7( "div", { className: "dxw-card dim-surfaceCard" }, - h6( + h7( "div", { className: "dxw-error dim-inlineError", role: "alert" }, - h6("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : "\u5FAE\u4FE1\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), - h6("p", null, error.message), - h6("span", { className: "dxw-errorCode" }, error.code), - h6( + h7("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : "\u5FAE\u4FE1\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), + h7("p", null, error.message), + h7("span", { className: "dxw-errorCode" }, error.code), + h7( "div", { className: "dxw-actions dim-viewActions" }, - h6(Button9, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), - h6(Button9, { onClick: onClose, disabled: busy }, "\u5173\u95ED") + h7(Button9, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h7(Button9, { onClick: onClose, disabled: busy }, "\u5173\u95ED") ) ) ); @@ -4524,70 +4903,71 @@ function checkedTime4(timestamp5) { function AccountCard4({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { const state = busy === "reconnect" ? "connecting" : account.state; const tone = account.connected ? "success" : state === "error" ? "error" : "warning"; - return h6( + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); + return h7( "article", { className: "dxw-card dim-botCard", tabIndex: -1, "data-bot-id": account.botId }, - h6( + h7( "div", { className: "dxw-cardBody dim-botCardBody" }, - h6( + h7( "div", { className: "dxw-accountTop dim-botCardTop" }, - h6( + h7( "div", { className: "dxw-accountIdentity dim-botIdentity" }, - h6("div", { className: "dxw-avatar dim-botAvatar", "aria-hidden": "true" }, h6(WeixinLogoGlyph, { size: 27 })), - h6("div", { className: "dim-botName" }, h6("h3", null, account.bot.name), h6("p", null, account.bot.accountIdMasked)) + h7("div", { className: "dxw-avatar dim-botAvatar", "aria-hidden": "true" }, h7(WeixinLogoGlyph, { size: 27 })), + h7("div", { className: "dim-botName" }, h7("h3", null, account.bot.name), h7("p", null, account.bot.accountIdMasked)) ), - h6( + h7( "div", { className: "dxw-health dim-botHealth" }, - h6("span", { className: "dxw-dot dim-healthDot", "data-tone": tone }), - h6("span", null, account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA") + h7("span", { className: "dxw-dot dim-healthDot", "data-tone": tone }), + h7("span", null, account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA") ) ), - h6( + h7( "dl", { className: "dxw-metrics dim-botMetrics" }, - h6( + h7( "div", { className: "dxw-metric dim-botMetric" }, - h6("dt", null, "\u6D88\u606F\u901A\u9053"), - h6("dd", null, account.connected ? "iLink \u957F\u8F6E\u8BE2" : "\u79BB\u7EBF") + h7("dt", null, "\u6D88\u606F\u901A\u9053"), + h7("dd", null, account.connected ? "iLink \u957F\u8F6E\u8BE2" : "\u79BB\u7EBF") ), - h6( + h7( "div", { className: "dxw-metric dim-botMetric" }, - h6("dt", null, "\u6700\u8FD1\u68C0\u67E5"), - h6("dd", null, checkedTime4(account.health.lastCheckedAt)) + h7("dt", null, "\u6700\u8FD1\u68C0\u67E5"), + h7("dd", null, checkedTime4(account.health.lastCheckedAt)) ) ), - h6( + h7( "div", { className: "dxw-accountFooter dim-cardFooter" }, - h6("div", { className: "dxw-summary dim-cardSummary" }, account.error?.message ?? account.health.summary), - h6( + summary ? h7("div", { className: "dxw-summary dim-cardSummary" }, summary) : null, + h7( "div", { className: "dxw-actions dim-cardActions" }, - h6( + h7( Button9, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5" ), - h6(Button9, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") + h7(Button9, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") ) ) ), - removing ? h6( + removing ? h7( "div", { className: "dxw-confirm dim-confirm", role: "alertdialog" }, - h6("strong", null, "\u4ECE\u6B64 Harness \u79FB\u9664\u8FD9\u4E2A\u5FAE\u4FE1\u8D26\u53F7\uFF1F"), - h6("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684 bot_token\u3001\u8D26\u53F7\u914D\u7F6E\u548C\u4F1A\u8BDD\u6620\u5C04\u3002\u5176\u4ED6\u5FAE\u4FE1\u8D26\u53F7\u4E0D\u53D7\u5F71\u54CD\u3002"), - h6( + h7("strong", null, "\u4ECE\u6B64 Harness \u79FB\u9664\u8FD9\u4E2A\u5FAE\u4FE1\u8D26\u53F7\uFF1F"), + h7("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684 bot_token\u3001\u8D26\u53F7\u914D\u7F6E\u548C\u4F1A\u8BDD\u6620\u5C04\u3002\u5176\u4ED6\u5FAE\u4FE1\u8D26\u53F7\u4E0D\u53D7\u5F71\u54CD\u3002"), + h7( "div", { className: "dxw-actions dim-viewActions" }, - h6(Button9, { onClick: onCancelRemove, disabled: busy === "delete" }, "\u4FDD\u7559\u8D26\u53F7"), - h6( + h7(Button9, { onClick: onCancelRemove, disabled: busy === "delete" }, "\u4FDD\u7559\u8D26\u53F7"), + h7( Button9, { kind: "danger", onClick: onConfirmRemove, disabled: busy === "delete" }, busy === "delete" ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664" @@ -4597,14 +4977,14 @@ function AccountCard4({ account, busy, removing, onReconnect, onRequestRemove, o ); } function AccountList2(props) { - return h6( + return h7( "section", { className: "dim-listSection" }, - h6("div", { className: "dxw-listHeading dim-listHeading" }, h6("h3", null, "\u5DF2\u63A5\u5165\u7684\u5FAE\u4FE1\u8D26\u53F7"), h6("span", null, `${props.bots.length} \u4E2A`)), - h6("ul", { className: "dxw-list dim-botList" }, props.bots.map((account) => h6( + h7("div", { className: "dxw-listHeading dim-listHeading" }, h7("h3", null, "\u5DF2\u63A5\u5165\u7684\u5FAE\u4FE1\u8D26\u53F7")), + h7("ul", { className: "dxw-list dim-botList" }, props.bots.map((account) => h7( "li", { key: account.botId }, - h6(AccountCard4, { + h7(AccountCard4, { account, busy: props.busyByBot[account.botId], removing: props.removeTarget === account.botId, @@ -4627,31 +5007,31 @@ function mergeWeixinProvisioningSnapshot(current, incoming, { restoreProvisionin }; } function WeixinSettingsTab({ rpcCall }) { - const [model, setModel] = React7.useState({ + const [model, setModel] = React8.useState({ phase: "loading", bots: [], totals: EMPTY_TOTALS3, revision: 0, error: null }); - const [provision, setProvision] = React7.useState(null); - const [busy, setBusy] = React7.useState(false); - const [busyByBot, setBusyByBot] = React7.useState({}); - const [removeTarget, setRemoveTarget] = React7.useState(null); - const [notice, setNotice] = React7.useState(""); - const [now, setNow] = React7.useState(() => Date.now()); - const addButtonRef = React7.useRef(null); + const [provision, setProvision] = React8.useState(null); + const [busy, setBusy] = React8.useState(false); + const [busyByBot, setBusyByBot] = React8.useState({}); + const [removeTarget, setRemoveTarget] = React8.useState(null); + const [notice, setNotice] = React8.useState(""); + const [now, setNow] = React8.useState(() => Date.now()); + const addButtonRef = React8.useRef(null); const scheduleAnimationFrame = useAnimationFrameScheduler(); - const announce = React7.useCallback((value) => { + const announce = React8.useCallback((value) => { setNotice(""); scheduleAnimationFrame(() => { if (value) setNotice(value); }, "announcement"); }, [scheduleAnimationFrame]); - const invoke = React7.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React8.useCallback(async (endpoint, payload = {}, signal) => { return unwrapRpcResult5(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React7.useCallback(async ({ + const loadStatus = React8.useCallback(async ({ signal, silent = false, restoreProvisioning = false @@ -4685,12 +5065,12 @@ function WeixinSettingsTab({ rpcCall }) { return void 0; } }, [invoke]); - React7.useEffect(() => { + React8.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); - React7.useEffect(() => { + React8.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); let running = false; @@ -4709,12 +5089,12 @@ function WeixinSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React7.useEffect(() => { + React8.useEffect(() => { if (!provision || !["pending", "scanned"].includes(provision.status)) return void 0; const timer = window.setInterval(() => setNow(Date.now()), 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); - const startProvisioning = React7.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React8.useCallback(async ({ replace = false } = {}) => { setBusy(true); try { if (replace && provision?.attemptId) { @@ -4735,7 +5115,7 @@ function WeixinSettingsTab({ rpcCall }) { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - const cancelProvisioning = React7.useCallback(async () => { + const cancelProvisioning = React8.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && !["failed", "expired", "cancelled"].includes(provision.status)) { @@ -4750,7 +5130,7 @@ function WeixinSettingsTab({ rpcCall }) { setBusy(false); } }, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]); - const submitVerification = React7.useCallback(async (verifyCode) => { + const submitVerification = React8.useCallback(async (verifyCode) => { if (!provision?.attemptId) return; setBusy(true); try { @@ -4766,7 +5146,7 @@ function WeixinSettingsTab({ rpcCall }) { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - React7.useEffect(() => { + React8.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !["pending", "scanned", "connecting"].includes(provision.status)) return void 0; const controller = new AbortController(); @@ -4814,7 +5194,7 @@ function WeixinSettingsTab({ rpcCall }) { controller.abort(); }; }, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]); - const setBotBusy = React7.useCallback((botId, value) => { + const setBotBusy = React8.useCallback((botId, value) => { setBusyByBot((current) => { const next = { ...current }; if (value) next[botId] = value; @@ -4822,7 +5202,7 @@ function WeixinSettingsTab({ rpcCall }) { return next; }); }, []); - const reconnect = React7.useCallback(async (account) => { + const reconnect = React8.useCallback(async (account) => { setBotBusy(account.botId, "reconnect"); try { const snapshot = normalizeSnapshot4(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId })); @@ -4835,7 +5215,7 @@ function WeixinSettingsTab({ rpcCall }) { setBotBusy(account.botId, null); } }, [announce, invoke, setBotBusy]); - const remove = React7.useCallback(async (account) => { + const remove = React8.useCallback(async (account) => { setBotBusy(account.botId, "delete"); try { const snapshot = normalizeSnapshot4(await invoke(WEIXIN_ENDPOINTS.deleteBot, { @@ -4853,9 +5233,9 @@ function WeixinSettingsTab({ rpcCall }) { }, [announce, invoke, setBotBusy]); let provisionView = null; if (provision?.status === "starting") { - provisionView = h6(ProgressPanel2, { busy }); + provisionView = h7(ProgressPanel2, { busy }); } else if (["pending", "scanned"].includes(provision?.status)) { - provisionView = h6(QrPanel4, { + provisionView = h7(QrPanel4, { provision, now, busy, @@ -4863,54 +5243,54 @@ function WeixinSettingsTab({ rpcCall }) { onCancel: () => void cancelProvisioning() }); } else if (provision?.status === "needs_verification") { - provisionView = h6(VerificationPanel, { + provisionView = h7(VerificationPanel, { provision, busy, onSubmit: (code) => void submitVerification(code), onCancel: () => void cancelProvisioning() }); } else if (provision?.status === "connecting") { - provisionView = h6(ProgressPanel2, { + provisionView = h7(ProgressPanel2, { scanned: true, busy, onCancel: () => void cancelProvisioning() }); } else if (provision && ["failed", "expired", "cancelled"].includes(provision.status)) { - provisionView = h6(ProvisionError3, { + provisionView = h7(ProvisionError3, { provision, busy, onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }), onClose: () => void cancelProvisioning() }); } - return h6( + return h7( "section", { className: "dxw-page dim-channelPage", "aria-label": "\u5FAE\u4FE1\u8BBE\u7F6E" }, - h6(Heading5, { + h7(Heading5, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }), - h6("div", { className: "dxw-visuallyHidden", role: "status", "aria-live": "polite" }, notice), - model.error && model.phase === "ready" ? h6("div", { className: "dxw-statusNotice dim-statusNotice" }, `\u72B6\u6001\u5237\u65B0\u5931\u8D25\uFF1A${model.error.message}`) : null, - model.phase === "loading" ? h6(LoadingView5) : model.phase === "error" ? h6( + h7("div", { className: "dxw-visuallyHidden", role: "status", "aria-live": "polite" }, notice), + model.error && model.phase === "ready" ? h7("div", { className: "dxw-statusNotice dim-statusNotice" }, `\u72B6\u6001\u5237\u65B0\u5931\u8D25\uFF1A${model.error.message}`) : null, + model.phase === "loading" ? h7(LoadingView5) : model.phase === "error" ? h7( "div", { className: "dxw-card dim-surfaceCard" }, - h6( + h7( "div", { className: "dxw-error dim-inlineError" }, - h6("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u5FAE\u4FE1\u72B6\u6001"), - h6("p", null, model.error?.message ?? "\u8BF7\u7A0D\u540E\u91CD\u8BD5"), - h6(Button9, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") + h7("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u5FAE\u4FE1\u72B6\u6001"), + h7("p", null, model.error?.message ?? "\u8BF7\u7A0D\u540E\u91CD\u8BD5"), + h7(Button9, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") ) - ) : h6( - React7.Fragment, + ) : h7( + React8.Fragment, null, provisionView, - model.bots.length === 0 && !provision ? h6(EmptyView5, { onStart: () => void startProvisioning(), busy }) : null, - model.bots.length > 0 ? h6(AccountList2, { + model.bots.length === 0 && !provision ? h7(EmptyView5, { onStart: () => void startProvisioning(), busy }) : null, + model.bots.length > 0 ? h7(AccountList2, { bots: model.bots, busyByBot, removeTarget, @@ -4951,7 +5331,7 @@ var CSS6 = String.raw` .dim-logoWeixin { color: white; background: #07c160; } .dim-logoWeixin svg { width: 19px; height: 19px; } .dim-logoFeishu { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); } -.dim-logoFeishu svg { width: 22px; height: 22px; } +.dim-logoFeishu svg { width: 28px; height: 28px; } .dim-logoDingtalk { color: white; background: #1677ff; } .dim-logoDingtalk svg { width: 24px; height: 24px; } .dim-logoQq { color: white; background: #1677ff; } @@ -4965,17 +5345,31 @@ var CSS6 = String.raw` .dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page { width: 100%; max-width: none; padding: 0 0 24px; } .dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; } .dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; justify-content: space-between; } -.dim-panel .bxf-headingTools .dim-scanButton, .dim-panel .dxw-tools .dim-scanButton, .dim-panel .ddt-tools .dim-scanButton { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; box-shadow: none; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; } +.dim-panel .dim-bindActions { min-width: 0; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; } +.dim-panel .bxf-headingTools .dim-scanButton, .dim-panel .dxw-tools .dim-scanButton, .dim-panel .ddt-tools .dim-scanButton { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 10px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; box-shadow: none; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; } .dim-panel .bxf-headingTools .dim-scanButton:hover:not(:disabled), .dim-panel .dxw-tools .dim-scanButton:hover:not(:disabled), .dim-panel .ddt-tools .dim-scanButton:hover:not(:disabled) { border-color: #0958d9; background: #0958d9; } +.dim-panel .dim-credentialButton { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 10px; border: 1px solid #86909c; border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 5%); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; } +.dim-panel .dim-actionIcon { width: 15px; height: 15px; flex: 0 0 15px; } +.dim-panel .dim-credentialButton:hover:not(:disabled) { border-color: #4e5969; background: var(--dsw-alias-fill-tertiary, #f7f8fa); } +.dim-panel .dim-credentialButton[aria-pressed="true"] { border-color: #4e5969; background: var(--dsw-alias-fill-secondary, #f2f3f5); box-shadow: inset 0 0 0 1px rgb(78 89 105 / 8%); } .dim-panel .bxf-headingTools .dim-onlineBadge, .dim-panel .dxw-tools .dim-onlineBadge, .dim-panel .ddt-tools .dim-onlineBadge { flex: none; min-height: 30px; display: inline-flex; align-items: center; gap: 0; padding: 0 11px; border: 0; border-radius: 999px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-fill-secondary, #f2f3f5); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; white-space: nowrap; } .dim-panel .dim-channelPage { width: 100%; max-width: none; display: flex; flex-direction: column; gap: 18px; padding: 0 0 24px; color: var(--dsw-alias-label-primary, #1f2329); box-sizing: border-box; } .dim-panel .dim-surfaceCard { position: relative; overflow: hidden; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-body, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 3%); } .dim-panel .dim-surfaceCard::before { display: none; } .dim-panel .dim-surfaceBody { padding: 24px; } +.dim-panel .dim-credentialPanel { display: grid; gap: 18px; padding: 20px; } +.dim-panel .dim-credentialTitle { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: 1.35; font-weight: 650; } +.dim-panel .dim-credentialForm { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 12px; } +.dim-panel .dim-credentialField { min-width: 0; display: grid; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; font-weight: 560; } +.dim-panel .dim-credentialField input { width: 100%; min-width: 0; height: 38px; padding: 0 11px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; transition: border-color .16s ease, box-shadow .16s ease; } +.dim-panel .dim-credentialField input:focus { border-color: #4e5969; box-shadow: 0 0 0 3px rgb(78 89 105 / 10%); } +.dim-panel .dim-credentialField input::placeholder { color: var(--dsw-alias-label-tertiary, #8f959e); font-family: inherit; } +.dim-panel .dim-credentialError, .dim-panel .dim-credentialActions { grid-column: 1 / -1; } +.dim-panel .dim-credentialError { margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; } +.dim-panel .dim-credentialActions { margin-top: 0; } .dim-panel .dim-listSection { display: flex; flex-direction: column; gap: 0; } .dim-panel .dim-listHeading { min-height: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 2px 0 9px; padding: 0; } .dim-panel .dim-listHeading h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: normal; font-weight: 650; } -.dim-panel .dim-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: normal; } .dim-panel .dim-botList { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; } .dim-panel .dim-loadingView { padding: 38px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; } .dim-panel .dim-loadingView h3 { margin: 0 0 7px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: normal; font-weight: 650; } @@ -5026,7 +5420,7 @@ var CSS6 = String.raw` .dim-panel .dim-confirm p { margin: 7px 0 0; color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.6; } .dim-panel .dim-cardFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-line-divider, #eef0f3); } .dim-panel .dim-cardSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; } -.dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin-top: 0; } +.dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin: 0 0 0 auto; } .dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; } .dim-panel .dim-cardActions .dim-cardAction:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-fill-tertiary, #f7f8fa); } .dim-panel .dim-cardActions .dim-cardAction[data-kind="danger"] { color: var(--dsw-alias-state-error-primary, #d54941); } @@ -5053,6 +5447,8 @@ var CSS6 = String.raw` .dim-panel .ddt-headingCopy { display: none; } .dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); } @container (max-width: 680px) { + .dim-panel .dim-credentialForm { grid-template-columns: minmax(0, 1fr); } + .dim-panel .dim-credentialError, .dim-panel .dim-credentialActions { grid-column: auto; } .dim-panel .dim-emptyView { min-height: 0; grid-template-columns: minmax(0, 1fr); } .dim-panel .dim-emptyBrand { display: none; } .dim-panel .dim-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; gap: 24px; } @@ -5097,7 +5493,7 @@ function installImStyles() { } // plugin-src/client/index.js -var h7 = React8.createElement; +var h8 = React9.createElement; var name = "im-settings"; var inject = ["slots", "connection"]; var CHANNELS = Object.freeze([ @@ -5108,57 +5504,57 @@ var CHANNELS = Object.freeze([ { id: "qq", label: "QQ" } ]); function WeixinLogo() { - return h7( + return h8( "span", { className: "dim-logo dim-logoWeixin", "aria-hidden": "true" }, - h7(WeixinLogoGlyph) + h8(WeixinLogoGlyph) ); } function FeishuLogo() { - return h7( + return h8( "span", { className: "dim-logo dim-logoFeishu", "aria-hidden": "true" }, - h7(FeishuLogoGlyph) + h8(FeishuLogoGlyph) ); } function DingtalkLogo() { - return h7( + return h8( "span", { className: "dim-logo dim-logoDingtalk", "aria-hidden": "true" }, - h7(DingtalkLogoGlyph) + h8(DingtalkLogoGlyph) ); } function QqLogo() { - return h7("span", { className: "dim-logo dim-logoQq", "aria-hidden": "true" }, h7(QqLogoGlyph)); + return h8("span", { className: "dim-logo dim-logoQq", "aria-hidden": "true" }, h8(QqLogoGlyph)); } function WecomLogo() { - return h7("span", { className: "dim-logo dim-logoWecom", "aria-hidden": "true" }, h7(WecomLogoGlyph)); + return h8("span", { className: "dim-logo dim-logoWecom", "aria-hidden": "true" }, h8(WecomLogoGlyph)); } function ChannelLogo({ channel }) { - if (channel === "weixin") return h7(WeixinLogo); - if (channel === "feishu") return h7(FeishuLogo); - if (channel === "dingtalk") return h7(DingtalkLogo); - if (channel === "wecom") return h7(WecomLogo); - return h7(QqLogo); + if (channel === "weixin") return h8(WeixinLogo); + if (channel === "feishu") return h8(FeishuLogo); + if (channel === "dingtalk") return h8(DingtalkLogo); + if (channel === "wecom") return h8(WecomLogo); + return h8(QqLogo); } function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }) { - const [selected, setSelected] = React8.useState("weixin"); + const [selected, setSelected] = React9.useState("weixin"); const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0]; - return h7( + return h8( "section", { className: "dim-page", "aria-label": "IM\u673A\u5668\u4EBA\u8BBE\u7F6E" }, - h7( + h8( "header", { className: "dim-title" }, - h7("p", null, "\u901A\u8FC7\u626B\u7801\u628A\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness") + h8("p", null, "\u901A\u8FC7\u626B\u7801\u628A\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness") ), - h7( + h8( "div", { className: "dim-layout" }, - h7( + h8( "nav", { className: "dim-rail", role: "tablist", "aria-label": "IM \u6E20\u9053" }, - CHANNELS.map((channel) => h7( + CHANNELS.map((channel) => h8( "button", { key: channel.id, @@ -5170,21 +5566,21 @@ function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall "aria-controls": `dim-panel-${channel.id}`, onClick: () => setSelected(channel.id) }, - h7(ChannelLogo, { channel: channel.id }), - h7( + h8(ChannelLogo, { channel: channel.id }), + h8( "span", { className: "dim-channelCopy" }, - h7("strong", null, channel.label) + h8("strong", null, channel.label) ) )) ), - h7("div", { className: "dim-divider", "aria-hidden": "true" }), - h7("main", { + h8("div", { className: "dim-divider", "aria-hidden": "true" }), + h8("main", { className: "dim-panel", role: "tabpanel", id: `dim-panel-${active.id}`, "aria-labelledby": `dim-tab-${active.id}` - }, active.id === "weixin" ? h7(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h7(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === "dingtalk" ? h7(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : active.id === "wecom" ? h7(WecomSettingsTab, { rpcCall: wecomRpcCall }) : h7(QqSettingsTab, { rpcCall: qqRpcCall })) + }, active.id === "weixin" ? h8(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h8(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === "dingtalk" ? h8(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : active.id === "wecom" ? h8(WecomSettingsTab, { rpcCall: wecomRpcCall }) : h8(QqSettingsTab, { rpcCall: qqRpcCall })) ) ); } diff --git a/lib/index.js b/lib/index.js index 93d5ceb..7de68ea 100644 --- a/lib/index.js +++ b/lib/index.js @@ -680,6 +680,49 @@ var DingtalkController = class { signal?.removeEventListener("abort", abortFromRequest); } } + /** Binds one DingTalk application with an existing Client ID and Client Secret. */ + async bindCredentials({ clientId, clientSecret } = {}) { + if (this.#closed) throw new Error("dsh-dingtalk controller is closed"); + const normalizedClientId = cleanString3(clientId); + const normalizedSecret = cleanString3(clientSecret); + if (!normalizedClientId || !normalizedSecret) { + throw new TypeError("DingTalk Client ID and Client Secret are required"); + } + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + if (this.#closed) throw new Error("dsh-dingtalk controller is closed"); + const identity = deriveDingtalkBotIdentity(normalizedClientId); + await this.#withBotTransition(identity.botId, async () => { + if (this.#closed) throw abortError(); + const previousConfig = this.#configStore.getByClientId(normalizedClientId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0); + if (this.#closed) throw abortError(); + const config = { + botId: identity.botId, + clientId: normalizedClientId, + secretRef: identity.secretRef, + approvedSenders: previousConfig?.approvedSenders ?? [] + }; + await this.#credentials.set(identity.secretRef, normalizedSecret); + try { + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + try { + await this.#startRuntime(config, normalizedSecret); + this.#errors.delete(identity.botId); + } catch { + this.#errors.set( + identity.botId, + safeError("connection-failed", "\u9489\u9489\u5DF2\u63A5\u5165\uFF0C\u4F46\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002") + ); + this.#logger.warn?.("[dsh-dingtalk] credential-bound bot saved but its connection is not ready"); + } + this.#touch(); + }); + return this.status(); + } /** Polls one QR registration without exposing its device code or returned secret. */ async registrationStatus(attemptId) { const record = this.#attempts.get(attemptId); @@ -3024,6 +3067,7 @@ var DINGTALK_ENDPOINTS = Object.freeze({ beginProvisioning: "provision.begin", pollProvisioning: "provision.poll", cancelProvisioning: "provision.cancel", + bindCredentials: "bot.bind-credentials", reconnectBot: "bot.reconnect", deleteBot: "bot.delete", approveSender: "bot.sender.approve", @@ -3051,6 +3095,9 @@ function exactKeys(value, allowed) { function validId(value) { return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); } +function validCredential(value, maxLength) { + return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength; +} function payloadFailure(endpoint, payload) { if (!isRecord(payload)) return "Payload must be an object."; if (endpoint === DINGTALK_ENDPOINTS.status) { @@ -3062,6 +3109,9 @@ function payloadFailure(endpoint, payload) { if ([DINGTALK_ENDPOINTS.pollProvisioning, DINGTALK_ENDPOINTS.cancelProvisioning].includes(endpoint)) { return exactKeys(payload, ["attemptId"]) && validId(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; } + if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) { + return exactKeys(payload, ["clientId", "clientSecret"]) && validCredential(payload.clientId, 256) && validCredential(payload.clientSecret, 1024) ? null : "bot.bind-credentials requires Client ID and Client Secret."; + } if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) { return exactKeys(payload, ["botId"]) && validId(payload.botId) ? null : "bot.reconnect requires a botId."; } @@ -3125,6 +3175,7 @@ function assertController(controller) { "startProvisioning", "registrationStatus", "cancelProvisioning", + "bindCredentials", "reconnectBot", "deleteBot", "approveSender", @@ -3171,6 +3222,8 @@ function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { value = await controller.cancelProvisioning(payload.attemptId); if (!value) return badRequest("The provisioning attempt no longer exists."); value = sanitizePublic(value); + } else if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) { + value = await publicStatus(await controller.bindCredentials(payload), cachedEncode); } else if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) { value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode); } else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) { @@ -3591,6 +3644,7 @@ function isBotSender(event) { } function isAllowedSender(event, allowedOpenIds) { if (!allowedOpenIds || allowedOpenIds.size === 0) return false; + if (allowedOpenIds.has("*")) return true; const senderOpenId = event?.sender?.sender_id?.open_id; return typeof senderOpenId === "string" && allowedOpenIds.has(senderOpenId); } @@ -4838,6 +4892,7 @@ var ACTIVE_REGISTRATION_STATES2 = /* @__PURE__ */ new Set([ "domain_switched" ]); var MUTABLE_REGISTRATION_STATES = /* @__PURE__ */ new Set([...ACTIVE_REGISTRATION_STATES2, "saving"]); +var ALL_VISIBLE_SENDERS = "*"; function idleConnection() { return { ready: false, @@ -5029,6 +5084,68 @@ var MultiBotDshFeishuController = class { selectedBotId: botId }); } + async bindCredentials({ appId, appSecret, domain = "feishu" } = {}) { + this.#assertOpen(); + const normalizedAppId = typeof appId === "string" ? appId.trim() : ""; + const normalizedSecret = typeof appSecret === "string" ? appSecret.trim() : ""; + const normalizedDomain = domain === "lark" ? "lark" : "feishu"; + if (!normalizedAppId || !normalizedSecret) { + throw new TypeError("Feishu App ID and App Secret are required"); + } + return this.#serializeConfig(async () => { + this.#assertOpen(); + const bot = await this.#verifyApp({ + appId: normalizedAppId, + appSecret: normalizedSecret, + domain: normalizedDomain + }); + this.#assertOpen(); + const existing = this.#configStore.list().find( + (candidate) => candidate.appId === normalizedAppId + ); + const botId = existing?.id ?? this.#createBotId(); + if (typeof botId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(botId) || !existing && this.#configStore.getBot(botId)) { + throw new Error("Bot id generator returned an invalid or duplicate id"); + } + const secretRef = existing?.secretRef ?? secretRefFor(botId); + const previousSecret = await this.#credentials.resolve(secretRef).catch(() => void 0); + const config = { + ...existing, + id: botId, + appId: normalizedAppId, + secretRef, + ownerOpenIds: existing?.ownerOpenIds?.length ? existing.ownerOpenIds : [ALL_VISIBLE_SENDERS], + domain: normalizedDomain, + botName: bot.name, + botOpenId: bot.openId, + activated: bot.activated, + deletionPending: false, + connectedAt: (/* @__PURE__ */ new Date()).toISOString(), + createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString() + }; + await this.#credentials.set(secretRef, normalizedSecret); + let saved; + try { + saved = await this.#configStore.saveBot(config); + } catch (error) { + await this.#restoreCredential(secretRef, previousSecret); + throw error; + } + await this.#withBotTransition(botId, async () => { + try { + await this.#startRuntime(saved, normalizedSecret); + this.#botErrors.delete(botId); + } catch { + this.#botErrors.set(botId, { + code: "connection_failed", + message: "\u673A\u5668\u4EBA\u5DF2\u7ECF\u7ED1\u5B9A\uFF0C\u4F46\u957F\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u8BF7\u70B9\u51FB\u91CD\u8BD5\u3002" + }); + } + }); + this.#touch(); + return this.status(botId); + }); + } async reconnectBot(botId) { this.#assertOpen(); return this.#withBotTransition(botId, async () => { @@ -5571,6 +5688,7 @@ var FEISHU_ENDPOINTS = Object.freeze({ beginProvisioning: "provision.begin", pollProvisioning: "provision.poll", cancelProvisioning: "provision.cancel", + bindCredentials: "bot.bind-credentials", reconnectBot: "bot.reconnect", disconnectBot: "bot.disconnect", deleteBot: "bot.delete", @@ -5642,6 +5760,9 @@ function finiteNumber(value) { function safeOpaqueId(value) { return typeof value === "string" && SAFE_ID.test(value); } +function validCredential2(value, maxLength) { + return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength; +} function publicError2(error) { if (!error || typeof error !== "object") return null; const code = typeof error.code === "string" && Object.hasOwn(PUBLIC_ERROR_MESSAGES, error.code) ? error.code : "registration_failed"; @@ -5795,6 +5916,9 @@ function validPayload(endpoint, payload) { } return null; } + if (endpoint === FEISHU_ENDPOINTS.bindCredentials) { + return hasOnlyKeys(payload, /* @__PURE__ */ new Set(["appId", "appSecret"])) && validCredential2(payload.appId, 256) && validCredential2(payload.appSecret, 1024) ? null : "Credential binding requires App ID and App Secret."; + } if (endpoint === FEISHU_ENDPOINTS.pollProvisioning || endpoint === FEISHU_ENDPOINTS.cancelProvisioning) { return hasOnlyKeys(payload, /* @__PURE__ */ new Set(["attemptId"])) && safeOpaqueId(payload.attemptId) ? null : "A single valid attemptId is required."; } @@ -5929,6 +6053,14 @@ function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl } = {}) { if (url) qrCache.delete(url); attemptQr.delete(payload.attemptId); value = { status: "failed", message: "Registration was cancelled." }; + } else if (endpoint === FEISHU_ENDPOINTS.bindCredentials) { + if (typeof controller.bindCredentials !== "function") { + throw new Error("Credential binding is unavailable"); + } + value = await toPublicFeishuStatus( + await controller.bindCredentials(payload), + { encodeQr: cachedEncodeQr } + ); } else if (endpoint === FEISHU_ENDPOINTS.testConnection) { const current = await controller.status(); const alreadyConnected = current?.connected === true || connectionFacts(current?.connection).connected; @@ -6532,6 +6664,47 @@ var QqController = class { registrationStatus(attemptId) { return publicAttempt2(this.#attempts.get(attemptId)); } + async bindCredentials({ appId, appSecret } = {}) { + if (this.#closed) throw new Error("QQ controller is closed"); + const normalizedAppId = cleanString6(appId); + const normalizedSecret = cleanString6(appSecret); + if (!normalizedAppId || !normalizedSecret) { + throw new TypeError("QQ AppID and AppSecret are required"); + } + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + if (this.#closed) throw new Error("QQ controller is closed"); + const identity = deriveQqBotIdentity(normalizedAppId); + await this.#withBotTransition(identity.botId, async () => { + if (this.#closed) throw new Error("QQ controller is closed"); + const previousConfig = this.#configStore.getByAppId(normalizedAppId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0); + if (this.#closed) throw new Error("QQ controller is closed"); + const config = { + botId: identity.botId, + appId: normalizedAppId, + secretRef: identity.secretRef, + ownerUserOpenid: previousConfig?.ownerUserOpenid ?? "*", + createdAt: previousConfig?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: (/* @__PURE__ */ new Date()).toISOString() + }; + await this.#credentials.set(identity.secretRef, normalizedSecret); + try { + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + try { + await this.#startRuntime(config, normalizedSecret); + this.#errors.delete(identity.botId); + } catch (error) { + this.#errors.set(identity.botId, safeError2("connection-failed", "QQ \u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002")); + this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error); + } + this.#touch(); + }); + return this.status(); + } async cancelProvisioning(attemptId) { const record = this.#attempts.get(attemptId); if (!record) return null; @@ -6627,6 +6800,7 @@ var QqController = class { if (this.#closed) return; this.#closed = true; if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + await Promise.allSettled([...this.#transitions.values()]); await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); } async #completeProvisioning(record, credentials) { @@ -6701,7 +6875,9 @@ var QqController = class { }); } async #startRuntime(config, appSecret) { + if (this.#closed) throw new Error("QQ controller is closed"); await this.#stopRuntime(config.botId); + if (this.#closed) throw new Error("QQ controller is closed"); const runtime = await this.#createRuntime({ botId: config.botId, config, appSecret }); if (!runtime || typeof runtime.start !== "function" || typeof runtime.stop !== "function") { throw new TypeError("createRuntime returned an invalid QQ runtime"); @@ -6824,7 +7000,7 @@ var QqHarnessBridge = class { if (!["c2c", "group"].includes(message.kind) || this.#state.hasSeen(messageId)) return; this.#status.messagesReceived += 1; this.#status.lastMessageAt = (/* @__PURE__ */ new Date()).toISOString(); - if (sender !== this.#ownerUserOpenid) { + if (this.#ownerUserOpenid !== "*" && sender !== this.#ownerUserOpenid) { this.#status.messagesRejected += 1; this.#status.lastRejectedAt = (/* @__PURE__ */ new Date()).toISOString(); return; @@ -7012,7 +7188,7 @@ var QqRuntime = class { }); bot.use?.(this.#typingMiddleware({ keepAlive: true, - predicate: (ctx) => ctx?.message?.senderId === this.#config.ownerUserOpenid + predicate: (ctx) => this.#config.ownerUserOpenid === "*" || ctx?.message?.senderId === this.#config.ownerUserOpenid })); const controller = new AbortController(); this.#abortController = controller; @@ -7426,6 +7602,7 @@ var QQ_ENDPOINTS = Object.freeze({ beginProvisioning: "provision.begin", pollProvisioning: "provision.poll", cancelProvisioning: "provision.cancel", + bindCredentials: "bot.bind-credentials", reconnectBot: "bot.reconnect", deleteBot: "bot.delete" }); @@ -7447,6 +7624,9 @@ function exactKeys2(value, allowed) { function validId2(value) { return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); } +function validCredential3(value, maxLength) { + return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength; +} function payloadFailure2(endpoint, payload) { if (!isRecord2(payload)) return "Payload must be an object."; if (endpoint === QQ_ENDPOINTS.status) return exactKeys2(payload, []) ? null : "connection.status does not accept fields."; @@ -7456,6 +7636,9 @@ function payloadFailure2(endpoint, payload) { if ([QQ_ENDPOINTS.pollProvisioning, QQ_ENDPOINTS.cancelProvisioning].includes(endpoint)) { return exactKeys2(payload, ["attemptId"]) && validId2(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; } + if (endpoint === QQ_ENDPOINTS.bindCredentials) { + return exactKeys2(payload, ["appId", "appSecret"]) && validCredential3(payload.appId, 256) && validCredential3(payload.appSecret, 1024) ? null : "bot.bind-credentials requires AppID and AppSecret."; + } if (endpoint === QQ_ENDPOINTS.reconnectBot) { return exactKeys2(payload, ["botId"]) && validId2(payload.botId) ? null : "bot.reconnect requires a botId."; } @@ -7491,7 +7674,7 @@ async function publicStatus2(status, encodeQr) { return sanitizePublic2(value); } function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { - for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "reconnectBot", "deleteBot"]) { + for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "bindCredentials", "reconnectBot", "deleteBot"]) { if (typeof controller?.[method] !== "function") throw new TypeError(`A complete QQ controller is required (${method})`); } const qrCache = /* @__PURE__ */ new Map(); @@ -7519,6 +7702,8 @@ function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { value = await withEncodedQr2(current, cachedEncode); } else if (endpoint === QQ_ENDPOINTS.cancelProvisioning) { value = sanitizePublic2(await controller.cancelProvisioning(payload.attemptId)); + } else if (endpoint === QQ_ENDPOINTS.bindCredentials) { + value = await publicStatus2(await controller.bindCredentials(payload), cachedEncode); } else if (endpoint === QQ_ENDPOINTS.reconnectBot) { value = await publicStatus2(await controller.reconnectBot(payload.botId), cachedEncode); } else { @@ -8024,6 +8209,49 @@ var WecomController = class { await record.polling.catch(() => void 0); return publicAttempt3(record); } + async bindCredentials({ botId, secret } = {}) { + if (this.#closed) throw new Error("Enterprise WeChat controller is closed"); + const remoteBotId = cleanString9(botId); + const normalizedSecret = cleanString9(secret); + if (!remoteBotId || !normalizedSecret) { + throw new TypeError("Enterprise WeChat Bot ID and Secret are required"); + } + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + if (this.#closed) throw new Error("Enterprise WeChat controller is closed"); + const identity = deriveWecomBotIdentity(remoteBotId); + await this.#withBotTransition(identity.botId, async () => { + if (this.#closed) throw new Error("Enterprise WeChat controller is closed"); + const previousConfig = this.#configStore.getByRemoteBotId(remoteBotId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0); + if (this.#closed) throw new Error("Enterprise WeChat controller is closed"); + const config = { + botId: identity.botId, + remoteBotId, + secretRef: identity.secretRef, + createdAt: previousConfig?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: (/* @__PURE__ */ new Date()).toISOString() + }; + await this.#credentials.set(identity.secretRef, normalizedSecret); + try { + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + try { + await this.#startRuntime(config, normalizedSecret); + this.#errors.delete(identity.botId); + } catch { + this.#errors.set( + identity.botId, + safeError3("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002") + ); + this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} credential connection failed`); + } + this.#touch(); + }); + return this.status(); + } async cancelProvisioning(attemptId) { const record = this.#attempts.get(attemptId); if (!record) return null; @@ -8117,6 +8345,7 @@ var WecomController = class { if (this.#closed) return; this.#closed = true; if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + await Promise.allSettled([...this.#transitions.values()]); await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); } async #pollAttempt(record) { @@ -8213,7 +8442,9 @@ var WecomController = class { }); } async #startRuntime(config, secret) { + if (this.#closed) throw new Error("Enterprise WeChat controller is closed"); await this.#stopRuntime(config.botId); + if (this.#closed) throw new Error("Enterprise WeChat controller is closed"); const runtime = await this.#createRuntime({ botId: config.botId, config, secret }); if (!runtime || typeof runtime.start !== "function" || typeof runtime.stop !== "function") { throw new TypeError("createRuntime returned an invalid Enterprise WeChat runtime"); @@ -8907,6 +9138,7 @@ var WECOM_ENDPOINTS = Object.freeze({ beginProvisioning: "provision.begin", pollProvisioning: "provision.poll", cancelProvisioning: "provision.cancel", + bindCredentials: "bot.bind-credentials", reconnectBot: "bot.reconnect", deleteBot: "bot.delete" }); @@ -8929,6 +9161,9 @@ function exactKeys3(value, allowed) { function validId3(value) { return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); } +function validCredential4(value, maxLength) { + return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength; +} function payloadFailure3(endpoint, payload) { if (!isRecord3(payload)) return "Payload must be an object."; if (endpoint === WECOM_ENDPOINTS.status) return exactKeys3(payload, []) ? null : "connection.status does not accept fields."; @@ -8938,6 +9173,9 @@ function payloadFailure3(endpoint, payload) { if ([WECOM_ENDPOINTS.pollProvisioning, WECOM_ENDPOINTS.cancelProvisioning].includes(endpoint)) { return exactKeys3(payload, ["attemptId"]) && validId3(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; } + if (endpoint === WECOM_ENDPOINTS.bindCredentials) { + return exactKeys3(payload, ["botId", "secret"]) && validCredential4(payload.botId, 512) && validCredential4(payload.secret, 1024) ? null : "bot.bind-credentials requires Bot ID and Secret."; + } if (endpoint === WECOM_ENDPOINTS.reconnectBot) { return exactKeys3(payload, ["botId"]) && validId3(payload.botId) ? null : "bot.reconnect requires a botId."; } @@ -8973,7 +9211,7 @@ async function publicStatus3(status, encodeQr) { return sanitizePublic3(value); } function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { - for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "reconnectBot", "deleteBot"]) { + for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "bindCredentials", "reconnectBot", "deleteBot"]) { if (typeof controller?.[method] !== "function") { throw new TypeError(`A complete Enterprise WeChat controller is required (${method})`); } @@ -9006,6 +9244,8 @@ function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { value = await withEncodedQr3(current, cachedEncode); } else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) { value = sanitizePublic3(await controller.cancelProvisioning(payload.attemptId)); + } else if (endpoint === WECOM_ENDPOINTS.bindCredentials) { + value = await publicStatus3(await controller.bindCredentials(payload), cachedEncode); } else if (endpoint === WECOM_ENDPOINTS.reconnectBot) { value = await publicStatus3(await controller.reconnectBot(payload.botId), cachedEncode); } else { diff --git a/package.json b/package.json index 0b16253..287dbe3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@xmanrui/dsh-im", "version": "0.1.0", - "description": "通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。", + "description": "通过扫码或应用凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。", "license": "MIT", "type": "module", "repository": { diff --git a/plugin-src/client/channels/dingtalk/api.js b/plugin-src/client/channels/dingtalk/api.js index d74ccd9..8321967 100644 --- a/plugin-src/client/channels/dingtalk/api.js +++ b/plugin-src/client/channels/dingtalk/api.js @@ -5,6 +5,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({ beginProvisioning: 'provision.begin', pollProvisioning: 'provision.poll', cancelProvisioning: 'provision.cancel', + bindCredentials: 'bot.bind-credentials', reconnectBot: 'bot.reconnect', deleteBot: 'bot.delete', }); diff --git a/plugin-src/client/channels/dingtalk/index.js b/plugin-src/client/channels/dingtalk/index.js index df15e2c..48db31d 100644 --- a/plugin-src/client/channels/dingtalk/index.js +++ b/plugin-src/client/channels/dingtalk/index.js @@ -1,5 +1,6 @@ import * as React from 'react'; +import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js'; import { DINGTALK_ENDPOINTS, DINGTALK_RPC_CHANNEL, @@ -46,20 +47,30 @@ const Button = React.forwardRef(function Button( }, children); }); -function Heading({ totals, adding, busy, onAdd, addButtonRef }) { +function Heading({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) { return h('div', { className: 'ddt-heading' }, h('div', { className: 'ddt-headingCopy' }, h('div', { className: 'ddt-eyebrow' }, 'Channel'), h('h2', null, '钉钉机器人'), h('p', null, '通过扫码把钉钉机器人接入 DeepSeek Harness')), h('div', { className: 'ddt-tools' }, - h(Button, { - kind: 'primary', - className: 'dim-scanButton', - onClick: onAdd, - disabled: adding || busy, - ref: addButtonRef, - }, adding ? '正在接入' : '扫码接入钉钉'), + h('div', { className: 'dim-bindActions' }, + h(Button, { + kind: 'primary', + className: 'dim-scanButton', + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + 'aria-label': '扫码接入钉钉机器人', + }, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'), + h(Button, { + kind: 'credential', + className: 'dim-credentialButton', + onClick: onCredential, + disabled: adding || busy, + 'aria-pressed': credentialOpen, + 'aria-label': '使用 Client ID 和 Client Secret 绑定钉钉机器人', + }, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')), totals.configured > 0 ? h('div', { className: 'ddt-badge dim-onlineBadge' }, h('span', null, `${totals.connected} / ${totals.configured} 在线`)) @@ -204,6 +215,7 @@ export function AccountCard({ const state = busy === 'reconnect' ? 'connecting' : account.state; const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning'; const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪'; + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); return h('article', { className: 'ddt-card dim-botCard', tabIndex: -1, 'data-bot-id': account.botId }, h('div', { className: 'ddt-cardBody dim-botCardBody' }, h('div', { className: 'ddt-accountTop dim-botCardTop' }, @@ -220,7 +232,7 @@ export function AccountCard({ h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))), h('div', { className: 'ddt-accountFooter dim-cardFooter' }, - h('div', { className: 'ddt-summary dim-cardSummary' }, account.error?.message ?? account.health.summary), + summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null, h('div', { className: 'ddt-actions dim-cardActions' }, h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'), @@ -237,7 +249,7 @@ export function AccountCard({ function AccountList(props) { return h('section', { className: 'dim-listSection' }, h('div', { className: 'ddt-listHeading dim-listHeading' }, - h('h3', null, '已接入的钉钉机器人'), h('span', null, `${props.bots.length} 个`)), + h('h3', null, '已接入的钉钉机器人')), h('ul', { className: 'ddt-list dim-botList' }, props.bots.map((account) => h('li', { key: account.botId }, h(AccountCard, { account, @@ -260,6 +272,8 @@ export function DingtalkSettingsTab({ rpcCall }) { const [busy, setBusy] = React.useState(false); const [busyByBot, setBusyByBot] = React.useState({}); const [removeTarget, setRemoveTarget] = React.useState(null); + const [credentialOpen, setCredentialOpen] = React.useState(false); + const [credentialError, setCredentialError] = React.useState(null); const [notice, setNotice] = React.useState(''); const [now, setNow] = React.useState(() => Date.now()); const addButtonRef = React.useRef(null); @@ -397,6 +411,8 @@ export function DingtalkSettingsTab({ rpcCall }) { const startProvisioning = React.useCallback(async ({ replace = false } = {}) => { if (!mountedRef.current) return; + setCredentialOpen(false); + setCredentialError(null); setBusy(true); try { if (replace && provision?.attemptId) { @@ -432,6 +448,32 @@ export function DingtalkSettingsTab({ rpcCall }) { } }, [announce, invoke, provision?.attemptId]); + const bindCredentials = React.useCallback(async ({ identity, secret }) => { + if (!mountedRef.current) return; + setBusy(true); + setCredentialError(null); + try { + const snapshot = normalizeSnapshot(await invoke( + DINGTALK_ENDPOINTS.bindCredentials, + { clientId: identity, clientSecret: secret }, + )); + if (!mountedRef.current) return; + setModel({ + phase: 'ready', + bots: snapshot.bots, + totals: snapshot.totals, + revision: snapshot.revision, + error: null, + }); + setCredentialOpen(false); + announce('钉钉机器人凭据已绑定。'); + } catch (error) { + if (mountedRef.current) setCredentialError(presentError(error)); + } finally { + if (mountedRef.current) setBusy(false); + } + }, [announce, invoke]); + const cancelProvisioning = React.useCallback(async () => { if (!mountedRef.current) return; setBusy(true); @@ -598,12 +640,28 @@ export function DingtalkSettingsTab({ rpcCall }) { }); } + const credentialView = credentialOpen + ? h(CredentialBindingPanel, { + channel: '钉钉', + identityLabel: 'Client ID', + identityPlaceholder: '填写钉钉应用 Client ID', + secretLabel: 'Client Secret', + secretPlaceholder: '填写钉钉应用 Client Secret', + busy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { setCredentialOpen(false); setCredentialError(null); }, + }) + : null; + return h('section', { className: 'ddt-page dim-channelPage', 'aria-label': '钉钉设置' }, h(Heading, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), + onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); }, + credentialOpen, addButtonRef, }), h('div', { className: 'ddt-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice), @@ -619,8 +677,9 @@ export function DingtalkSettingsTab({ rpcCall }) { h('p', null, model.error?.message ?? '请稍后重试'), h(Button, { onClick: () => void loadStatus() }, '重新读取'))) : h(React.Fragment, null, + credentialView, provisionView, - model.bots.length === 0 && !provision + model.bots.length === 0 && !provision && !credentialOpen ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null, model.bots.length > 0 diff --git a/plugin-src/client/channels/dingtalk/styles.js b/plugin-src/client/channels/dingtalk/styles.js index 32dc0f4..86c55d1 100644 --- a/plugin-src/client/channels/dingtalk/styles.js +++ b/plugin-src/client/channels/dingtalk/styles.js @@ -79,7 +79,6 @@ const CSS = String.raw` .ddt-errorCode { font: 11px ui-monospace, SFMono-Regular, monospace; opacity: .8; } .ddt-listHeading { display: flex; align-items: center; justify-content: space-between; margin: 2px 0 9px; } .ddt-listHeading h3 { margin: 0; font-size: 14px; } -.ddt-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; } .ddt-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; } .ddt-accountTop { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } .ddt-accountIdentity { min-width: 0; display: flex; align-items: center; gap: 12px; } diff --git a/plugin-src/client/channels/feishu/api.js b/plugin-src/client/channels/feishu/api.js index c250beb..ab108f1 100644 --- a/plugin-src/client/channels/feishu/api.js +++ b/plugin-src/client/channels/feishu/api.js @@ -13,6 +13,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({ beginProvisioning: "provision.begin", pollProvisioning: "provision.poll", cancelProvisioning: "provision.cancel", + bindCredentials: "bot.bind-credentials", reconnectBot: "bot.reconnect", disconnectBot: "bot.disconnect", deleteBot: "bot.delete", diff --git a/plugin-src/client/channels/feishu/index.js b/plugin-src/client/channels/feishu/index.js index d018432..956d6fb 100644 --- a/plugin-src/client/channels/feishu/index.js +++ b/plugin-src/client/channels/feishu/index.js @@ -1,6 +1,7 @@ import * as React from "react"; import { FeishuLogoGlyph } from "../../channel-logos.js"; +import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from "../../credential-binding.js"; import { FEISHU_ENDPOINTS, FEISHU_RPC_CHANNEL, @@ -125,19 +126,32 @@ function BrandMark() { return h("div", { className: "bxf-brandMark" }, h(RobotIcon, { size: 34 })); } -function Heading({ totals, onAdd, adding, busy, addButtonRef }) { +function Heading({ totals, onAdd, onCredential, credentialOpen, adding, busy, addButtonRef }) { const hasBots = totals.configured > 0; return h("div", { className: "bxf-heading" }, h("div", { className: "bxf-headingTools" }, - h(Button, { - kind: "primary", - size: "small", - className: "bxf-bindButton dim-scanButton", - onClick: onAdd, - disabled: adding || busy, - ref: addButtonRef, - "aria-busy": busy ? "true" : undefined, - }, adding ? "正在绑定" : "扫码绑定机器人"), + h("div", { className: "dim-bindActions" }, + h(Button, { + kind: "primary", + size: "small", + className: "bxf-bindButton dim-scanButton", + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + "aria-busy": busy ? "true" : undefined, + "aria-label": "扫码接入飞书机器人", + icon: h(QrActionIcon), + }, adding ? "正在接入" : "扫码接入机器人"), + h(Button, { + kind: "credential", + size: "small", + className: "dim-credentialButton", + onClick: onCredential, + disabled: adding || busy, + "aria-pressed": credentialOpen, + "aria-label": "使用 App ID 和 App Secret 绑定飞书机器人", + icon: h(CredentialActionIcon), + }, credentialOpen ? "收起凭据" : "手动接入")), hasBots ? h("div", { className: "bxf-totalBadge dim-onlineBadge", @@ -368,7 +382,7 @@ export function BotCard({ : "error"; const summary = actionError?.message ?? connection.error?.message - ?? health.summary; + ?? (connected ? null : health.summary); const titleId = `bxf-bot-${connection.botId.replace(/[^a-zA-Z0-9_-]/g, "-")}`; return h("article", { className: "bxf-card bxf-botCard dim-botCard", @@ -397,8 +411,8 @@ export function BotCard({ h("dd", null, formatCheckedTime(health.lastCheckedAt))), ), h("div", { className: "bxf-connectedFooter dim-cardFooter" }, - h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined }, - summary), + summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined }, + summary) : null, h("div", { className: "bxf-actions bxf-botActions dim-cardActions" }, h(Button, { className: "dim-cardAction", onClick: onReconnect, @@ -426,8 +440,7 @@ export function BotCard({ function BotList(props) { return h("section", { className: "bxf-listSection dim-listSection", "aria-labelledby": "bxf-bot-list-title" }, h("div", { className: "bxf-listHeading dim-listHeading" }, - h("h3", { id: "bxf-bot-list-title" }, "已接入的机器人"), - h("span", null, `${props.bots.length} 个`)), + h("h3", { id: "bxf-bot-list-title" }, "已接入的机器人")), h("ul", { className: "bxf-botList dim-botList", role: "list" }, props.bots.map((bot) => h("li", { key: bot.botId }, h(BotCard, { @@ -501,6 +514,9 @@ export function FeishuSettingsTab({ rpcCall }) { }); const [pageBusy, setPageBusy] = React.useState(false); const [provisionBusy, setProvisionBusy] = React.useState(false); + const [credentialOpen, setCredentialOpen] = React.useState(false); + const [credentialBusy, setCredentialBusy] = React.useState(false); + const [credentialError, setCredentialError] = React.useState(null); const [busyByBot, setBusyByBot] = React.useState({}); const [errorsByBot, setErrorsByBot] = React.useState({}); const [removeTargetId, setRemoveTargetId] = React.useState(null); @@ -589,6 +605,8 @@ export function FeishuSettingsTab({ rpcCall }) { }, [focusBotId, model.bots]); const startProvisioning = React.useCallback(async ({ replace = false } = {}) => { + setCredentialOpen(false); + setCredentialError(null); setProvisionBusy(true); announce(""); const previousAttemptId = model.provisioning?.attemptId; @@ -627,6 +645,24 @@ export function FeishuSettingsTab({ rpcCall }) { } }, [announce, invoke, model.provisioning?.attemptId]); + const bindCredentials = React.useCallback(async ({ identity, secret }) => { + setCredentialBusy(true); + setCredentialError(null); + try { + const snapshot = normalizeBotsSnapshot(await invoke( + FEISHU_ENDPOINTS.bindCredentials, + { appId: identity, appSecret: secret }, + )); + mergeSnapshot(snapshot); + setCredentialOpen(false); + announce("飞书机器人凭据已绑定。"); + } catch (error) { + setCredentialError(presentError(error)); + } finally { + setCredentialBusy(false); + } + }, [announce, invoke, mergeSnapshot]); + const cancelProvisioning = React.useCallback(async () => { const attemptId = model.provisioning?.attemptId; setProvisionBusy(true); @@ -852,6 +888,20 @@ export function FeishuSettingsTab({ rpcCall }) { }); } + const credentialContent = credentialOpen + ? h(CredentialBindingPanel, { + channel: "飞书", + identityLabel: "App ID", + identityPlaceholder: "填写飞书开放平台 App ID", + secretLabel: "App Secret", + secretPlaceholder: "填写飞书开放平台 App Secret", + busy: credentialBusy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { setCredentialOpen(false); setCredentialError(null); }, + }) + : null; + const setCardRef = React.useCallback((botId, node) => { if (node) cardRefs.current.set(botId, node); else cardRefs.current.delete(botId); @@ -865,8 +915,10 @@ export function FeishuSettingsTab({ rpcCall }) { h(Heading, { totals: model.totals, onAdd: () => void startProvisioning(), + onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); }, + credentialOpen, adding: Boolean(provision), - busy: provisionBusy, + busy: provisionBusy || credentialBusy, addButtonRef, }), h("div", { @@ -887,8 +939,9 @@ export function FeishuSettingsTab({ rpcCall }) { busy: pageBusy, }) : h(React.Fragment, null, + credentialContent, provisionContent, - model.bots.length === 0 && !provision + model.bots.length === 0 && !provision && !credentialOpen ? h(EmptyView, { onStart: () => void startProvisioning(), busy: provisionBusy }) : null, model.bots.length > 0 diff --git a/plugin-src/client/channels/feishu/styles.js b/plugin-src/client/channels/feishu/styles.js index fe9192b..865a4c1 100644 --- a/plugin-src/client/channels/feishu/styles.js +++ b/plugin-src/client/channels/feishu/styles.js @@ -386,7 +386,6 @@ const CSS = String.raw` .bxf-listSection { display: flex; flex-direction: column; gap: 10px; } .bxf-listHeading { min-height: 28px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 2px; } .bxf-listHeading h3 { font-size: 14px; line-height: 22px; font-weight: 650; margin: 0; } -.bxf-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; } .bxf-botList { display: flex; flex-direction: column; gap: 12px; margin: 0; padding: 0; list-style: none; } .bxf-botList > li { min-width: 0; } .bxf-botCard:focus { outline: none; } diff --git a/plugin-src/client/channels/qq/api.js b/plugin-src/client/channels/qq/api.js index cf091cf..30b9c17 100644 --- a/plugin-src/client/channels/qq/api.js +++ b/plugin-src/client/channels/qq/api.js @@ -5,6 +5,7 @@ export const QQ_ENDPOINTS = Object.freeze({ beginProvisioning: 'provision.begin', pollProvisioning: 'provision.poll', cancelProvisioning: 'provision.cancel', + bindCredentials: 'bot.bind-credentials', reconnectBot: 'bot.reconnect', deleteBot: 'bot.delete', }); diff --git a/plugin-src/client/channels/qq/index.js b/plugin-src/client/channels/qq/index.js index 50381b6..76ac805 100644 --- a/plugin-src/client/channels/qq/index.js +++ b/plugin-src/client/channels/qq/index.js @@ -1,6 +1,7 @@ import * as React from 'react'; import { QqLogoGlyph } from '../../channel-logos.js'; +import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js'; import { installDingtalkStyles } from '../dingtalk/styles.js'; import { QQ_ENDPOINTS, @@ -38,16 +39,26 @@ function checkedTime(value) { } } -function Heading({ totals, adding, busy, onAdd, addButtonRef }) { +function Heading({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) { return h('div', { className: 'ddt-heading' }, h('div', { className: 'ddt-tools' }, - h(Button, { - kind: 'primary', - className: 'dim-scanButton', - onClick: onAdd, - disabled: adding || busy, - ref: addButtonRef, - }, adding ? '正在绑定' : '扫码绑定QQ机器人'), + h('div', { className: 'dim-bindActions' }, + h(Button, { + kind: 'primary', + className: 'dim-scanButton', + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + 'aria-label': '扫码接入 QQ 机器人', + }, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'), + h(Button, { + kind: 'credential', + className: 'dim-credentialButton', + onClick: onCredential, + disabled: adding || busy, + 'aria-pressed': credentialOpen, + 'aria-label': '使用 AppID 和 AppSecret 绑定 QQ 机器人', + }, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')), totals.configured > 0 ? h('div', { className: 'ddt-badge dim-onlineBadge' }, h('span', null, `${totals.connected} / ${totals.configured} 在线`)) @@ -138,6 +149,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) { export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning'; const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪'; + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId }, h('div', { className: 'ddt-cardBody dim-botCardBody' }, h('div', { className: 'ddt-accountTop dim-botCardTop' }, @@ -151,7 +163,7 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')), h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))), h('div', { className: 'ddt-accountFooter dim-cardFooter' }, - h('div', { className: 'ddt-summary dim-cardSummary' }, account.error?.message ?? account.health.summary), + summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null, h('div', { className: 'ddt-actions dim-cardActions' }, h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'), h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))), @@ -166,6 +178,8 @@ export function QqSettingsTab({ rpcCall }) { const [busy, setBusy] = React.useState(false); const [busyByBot, setBusyByBot] = React.useState({}); const [removeTarget, setRemoveTarget] = React.useState(null); + const [credentialOpen, setCredentialOpen] = React.useState(false); + const [credentialError, setCredentialError] = React.useState(null); const [now, setNow] = React.useState(Date.now()); const mounted = React.useRef(true); const addButtonRef = React.useRef(null); @@ -225,6 +239,8 @@ export function QqSettingsTab({ rpcCall }) { }, [provision?.attemptId, provision?.status]); const startProvisioning = React.useCallback(async (replace = false) => { + setCredentialOpen(false); + setCredentialError(null); setBusy(true); try { if (replace && provision?.attemptId) await invoke(QQ_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); @@ -241,6 +257,24 @@ export function QqSettingsTab({ rpcCall }) { } }, [invoke, provision?.attemptId]); + const bindCredentials = React.useCallback(async ({ identity, secret }) => { + setBusy(true); + setCredentialError(null); + try { + const snapshot = normalizeSnapshot(await invoke( + QQ_ENDPOINTS.bindCredentials, + { appId: identity, appSecret: secret }, + )); + if (!mounted.current) return; + setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null }); + setCredentialOpen(false); + } catch (error) { + if (mounted.current) setCredentialError(presentError(error)); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke]); + const closeProvision = React.useCallback(async () => { setBusy(true); try { @@ -308,7 +342,7 @@ export function QqSettingsTab({ rpcCall }) { const botList = model.bots.length > 0 ? h('section', { className: 'dim-listSection' }, h('div', { className: 'ddt-listHeading dim-listHeading' }, - h('h3', null, '已绑定的 QQ 机器人'), h('span', null, `${model.bots.length} 个`)), + h('h3', null, '已绑定的 QQ 机器人')), h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) => h('li', { key: account.botId }, h(AccountCard, { account, @@ -324,14 +358,38 @@ export function QqSettingsTab({ rpcCall }) { }))))) : null; + const credentialView = credentialOpen + ? h(CredentialBindingPanel, { + channel: 'QQ', + identityLabel: 'AppID', + identityPlaceholder: '填写 QQ 开放平台 AppID', + secretLabel: 'AppSecret', + secretPlaceholder: '填写 QQ 开放平台 AppSecret', + busy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { setCredentialOpen(false); setCredentialError(null); }, + }) + : null; + return h('section', { className: 'ddt-page dqq-page dim-channelPage', 'aria-label': 'QQ 设置' }, - h(Heading, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }), + h(Heading, { + totals: model.totals, + adding: Boolean(provision), + busy, + onAdd: () => void startProvisioning(), + onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); }, + credentialOpen, + addButtonRef, + }), model.phase === 'loading' ? h(LoadingView) : model.phase === 'error' ? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取 QQ 机器人状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取'))) : h(React.Fragment, null, + credentialView, provisionView, - model.bots.length === 0 && !provision ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null, + model.bots.length === 0 && !provision && !credentialOpen + ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null, botList)); } diff --git a/plugin-src/client/channels/wecom/api.js b/plugin-src/client/channels/wecom/api.js index 7f81850..8a81ada 100644 --- a/plugin-src/client/channels/wecom/api.js +++ b/plugin-src/client/channels/wecom/api.js @@ -5,6 +5,7 @@ export const WECOM_ENDPOINTS = Object.freeze({ beginProvisioning: 'provision.begin', pollProvisioning: 'provision.poll', cancelProvisioning: 'provision.cancel', + bindCredentials: 'bot.bind-credentials', reconnectBot: 'bot.reconnect', deleteBot: 'bot.delete', }); diff --git a/plugin-src/client/channels/wecom/index.js b/plugin-src/client/channels/wecom/index.js index 98fa64e..421c7f1 100644 --- a/plugin-src/client/channels/wecom/index.js +++ b/plugin-src/client/channels/wecom/index.js @@ -1,6 +1,7 @@ import * as React from 'react'; import { WecomLogoGlyph } from '../../channel-logos.js'; +import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js'; import { installDingtalkStyles } from '../dingtalk/styles.js'; import { WECOM_ENDPOINTS, @@ -38,16 +39,26 @@ function checkedTime(value) { } } -function Heading({ totals, adding, busy, onAdd, addButtonRef }) { +function Heading({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) { return h('div', { className: 'ddt-heading' }, h('div', { className: 'ddt-tools' }, - h(Button, { - kind: 'primary', - className: 'dim-scanButton', - onClick: onAdd, - disabled: adding || busy, - ref: addButtonRef, - }, adding ? '正在绑定' : '扫码绑定企业微信机器人'), + h('div', { className: 'dim-bindActions' }, + h(Button, { + kind: 'primary', + className: 'dim-scanButton', + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + 'aria-label': '扫码接入企业微信机器人', + }, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'), + h(Button, { + kind: 'credential', + className: 'dim-credentialButton', + onClick: onCredential, + disabled: adding || busy, + 'aria-pressed': credentialOpen, + 'aria-label': '使用 Bot ID 和 Secret 绑定企业微信机器人', + }, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')), totals.configured > 0 ? h('div', { className: 'ddt-badge dim-onlineBadge' }, h('span', null, `${totals.connected} / ${totals.configured} 在线`)) @@ -138,6 +149,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) { export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning'; const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪'; + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId }, h('div', { className: 'ddt-cardBody dim-botCardBody' }, h('div', { className: 'ddt-accountTop dim-botCardTop' }, @@ -151,7 +163,7 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')), h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))), h('div', { className: 'ddt-accountFooter dim-cardFooter' }, - h('div', { className: 'ddt-summary dim-cardSummary' }, account.error?.message ?? account.health.summary), + summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null, h('div', { className: 'ddt-actions dim-cardActions' }, h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'), h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))), @@ -166,6 +178,8 @@ export function WecomSettingsTab({ rpcCall }) { const [busy, setBusy] = React.useState(false); const [busyByBot, setBusyByBot] = React.useState({}); const [removeTarget, setRemoveTarget] = React.useState(null); + const [credentialOpen, setCredentialOpen] = React.useState(false); + const [credentialError, setCredentialError] = React.useState(null); const [now, setNow] = React.useState(Date.now()); const mounted = React.useRef(true); const addButtonRef = React.useRef(null); @@ -225,6 +239,8 @@ export function WecomSettingsTab({ rpcCall }) { }, [provision?.attemptId, provision?.status]); const startProvisioning = React.useCallback(async (replace = false) => { + setCredentialOpen(false); + setCredentialError(null); setBusy(true); try { if (replace && provision?.attemptId) await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); @@ -241,6 +257,24 @@ export function WecomSettingsTab({ rpcCall }) { } }, [invoke, provision?.attemptId]); + const bindCredentials = React.useCallback(async ({ identity, secret }) => { + setBusy(true); + setCredentialError(null); + try { + const snapshot = normalizeSnapshot(await invoke( + WECOM_ENDPOINTS.bindCredentials, + { botId: identity, secret }, + )); + if (!mounted.current) return; + setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null }); + setCredentialOpen(false); + } catch (error) { + if (mounted.current) setCredentialError(presentError(error)); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke]); + const closeProvision = React.useCallback(async () => { setBusy(true); try { @@ -308,7 +342,7 @@ export function WecomSettingsTab({ rpcCall }) { const botList = model.bots.length > 0 ? h('section', { className: 'dim-listSection' }, h('div', { className: 'ddt-listHeading dim-listHeading' }, - h('h3', null, '已绑定的企业微信机器人'), h('span', null, `${model.bots.length} 个`)), + h('h3', null, '已绑定的企业微信机器人')), h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) => h('li', { key: account.botId }, h(AccountCard, { account, @@ -324,14 +358,38 @@ export function WecomSettingsTab({ rpcCall }) { }))))) : null; + const credentialView = credentialOpen + ? h(CredentialBindingPanel, { + channel: '企业微信', + identityLabel: 'Bot ID', + identityPlaceholder: '填写企业微信智能机器人 Bot ID', + secretLabel: 'Secret', + secretPlaceholder: '填写企业微信智能机器人 Secret', + busy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { setCredentialOpen(false); setCredentialError(null); }, + }) + : null; + return h('section', { className: 'ddt-page dwecom-page dim-channelPage', 'aria-label': '企业微信设置' }, - h(Heading, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }), + h(Heading, { + totals: model.totals, + adding: Boolean(provision), + busy, + onAdd: () => void startProvisioning(), + onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); }, + credentialOpen, + addButtonRef, + }), model.phase === 'loading' ? h(LoadingView) : model.phase === 'error' ? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取企业微信机器人状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取'))) : h(React.Fragment, null, + credentialView, provisionView, - model.bots.length === 0 && !provision ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null, + model.bots.length === 0 && !provision && !credentialOpen + ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null, botList)); } diff --git a/plugin-src/client/channels/weixin/index.js b/plugin-src/client/channels/weixin/index.js index 5049fb7..e5fc20f 100644 --- a/plugin-src/client/channels/weixin/index.js +++ b/plugin-src/client/channels/weixin/index.js @@ -1,6 +1,7 @@ import * as React from 'react'; import { WeixinLogoGlyph } from '../../channel-logos.js'; +import { QrActionIcon } from '../../credential-binding.js'; import { WEIXIN_ENDPOINTS, WEIXIN_RPC_CHANNEL, @@ -42,7 +43,8 @@ function Heading({ totals, adding, busy, onAdd, addButtonRef }) { onClick: onAdd, disabled: adding || busy, ref: addButtonRef, - }, adding ? '正在绑定' : '扫码绑定微信'), + 'aria-label': '扫码接入微信机器人', + }, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'), totals.configured > 0 ? h('div', { className: 'dxw-badge dim-onlineBadge' }, h('span', null, `${totals.connected} / ${totals.configured} 在线`)) @@ -195,6 +197,7 @@ function checkedTime(timestamp) { export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { const state = busy === 'reconnect' ? 'connecting' : account.state; const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning'; + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); return h('article', { className: 'dxw-card dim-botCard', tabIndex: -1, 'data-bot-id': account.botId }, h('div', { className: 'dxw-cardBody dim-botCardBody' }, h('div', { className: 'dxw-accountTop dim-botCardTop' }, @@ -210,7 +213,7 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem h('div', { className: 'dxw-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))), h('div', { className: 'dxw-accountFooter dim-cardFooter' }, - h('div', { className: 'dxw-summary dim-cardSummary' }, account.error?.message ?? account.health.summary), + summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null, h('div', { className: 'dxw-actions dim-cardActions' }, h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'), @@ -227,7 +230,7 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem function AccountList(props) { return h('section', { className: 'dim-listSection' }, - h('div', { className: 'dxw-listHeading dim-listHeading' }, h('h3', null, '已接入的微信账号'), h('span', null, `${props.bots.length} 个`)), + h('div', { className: 'dxw-listHeading dim-listHeading' }, h('h3', null, '已接入的微信账号')), h('ul', { className: 'dxw-list dim-botList' }, props.bots.map((account) => h('li', { key: account.botId }, h(AccountCard, { account, diff --git a/plugin-src/client/channels/weixin/styles.js b/plugin-src/client/channels/weixin/styles.js index fcc94e1..6bf57eb 100644 --- a/plugin-src/client/channels/weixin/styles.js +++ b/plugin-src/client/channels/weixin/styles.js @@ -70,7 +70,6 @@ const CSS = String.raw` .dxw-errorCode { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11px; opacity: .8; } .dxw-listHeading { display: flex; justify-content: space-between; align-items: center; margin: 2px 0 9px; } .dxw-listHeading h3 { margin: 0; font-size: 14px; } -.dxw-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; } .dxw-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; } .dxw-accountTop { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } .dxw-accountIdentity { display: flex; align-items: center; gap: 12px; min-width: 0; } diff --git a/plugin-src/client/credential-binding.js b/plugin-src/client/credential-binding.js new file mode 100644 index 0000000..ab27e8f --- /dev/null +++ b/plugin-src/client/credential-binding.js @@ -0,0 +1,113 @@ +import * as React from 'react'; + +const h = React.createElement; + +function ActionIcon({ children }) { + return h('svg', { + className: 'dim-actionIcon', + width: 15, + height: 15, + viewBox: '0 0 20 20', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + 'aria-hidden': 'true', + focusable: 'false', + }, children); +} + +export function QrActionIcon() { + return h(ActionIcon, null, + h('path', { + d: 'M2.5 2.5h5v5h-5v-5Zm10 0h5v5h-5v-5Zm-10 10h5v5h-5v-5Z', + stroke: 'currentColor', strokeWidth: '1.6', strokeLinejoin: 'round', + }), + h('path', { + d: 'M11.5 11.5h2v2h-2v-2Zm4 0h2v3h-2v-3Zm-4 4h3v2h-3v-2Zm5 1h1v1h-1v-1Z', + fill: 'currentColor', + })); +} + +export function CredentialActionIcon() { + return h(ActionIcon, null, + h('circle', { + cx: '6.25', cy: '10', r: '3.5', stroke: 'currentColor', strokeWidth: '1.6', + }), + h('path', { + d: 'M9.75 10h7.75m-2.5 0v2m-2.5-2v2', + stroke: 'currentColor', strokeWidth: '1.6', strokeLinecap: 'round', strokeLinejoin: 'round', + })); +} + +export function CredentialBindingPanel({ + channel, + identityLabel, + identityPlaceholder, + secretLabel, + secretPlaceholder, + busy = false, + error = null, + onSubmit, + onCancel, +}) { + const [identity, setIdentity] = React.useState(''); + const [secret, setSecret] = React.useState(''); + const headingId = React.useId(); + + const submit = (event) => { + event.preventDefault(); + const normalizedIdentity = identity.trim(); + const normalizedSecret = secret.trim(); + if (!normalizedIdentity || !normalizedSecret || busy) return; + void onSubmit?.({ identity: normalizedIdentity, secret: normalizedSecret }); + }; + + return h('section', { + className: 'ddt-card dim-surfaceCard dim-credentialPanel', + 'aria-labelledby': headingId, + }, + h('h3', { id: headingId, className: 'dim-credentialTitle' }, `手动接入${channel}机器人`), + h('form', { className: 'dim-credentialForm', onSubmit: submit }, + h('label', { className: 'dim-credentialField' }, + h('span', null, identityLabel), + h('input', { + value: identity, + onChange: (event) => setIdentity(event.target.value), + placeholder: identityPlaceholder, + maxLength: 512, + autoCapitalize: 'none', + autoCorrect: 'off', + spellCheck: false, + autoComplete: 'off', + disabled: busy, + required: true, + })), + h('label', { className: 'dim-credentialField' }, + h('span', null, secretLabel), + h('input', { + type: 'password', + value: secret, + onChange: (event) => setSecret(event.target.value), + placeholder: secretPlaceholder, + maxLength: 1024, + autoCapitalize: 'none', + autoCorrect: 'off', + spellCheck: false, + autoComplete: 'new-password', + disabled: busy, + required: true, + })), + error ? h('p', { className: 'dim-credentialError', role: 'alert' }, error.message ?? String(error)) : null, + h('div', { className: 'ddt-actions dim-viewActions dim-credentialActions' }, + h('button', { + type: 'submit', + className: 'ddt-button', + 'data-kind': 'primary', + disabled: busy || !identity.trim() || !secret.trim(), + }, busy ? '正在绑定…' : '绑定并连接'), + h('button', { + type: 'button', + className: 'ddt-button', + onClick: onCancel, + disabled: busy, + }, '取消')))); +} diff --git a/plugin-src/client/styles.js b/plugin-src/client/styles.js index ca9671c..adfaf01 100644 --- a/plugin-src/client/styles.js +++ b/plugin-src/client/styles.js @@ -26,7 +26,7 @@ const CSS = String.raw` .dim-logoWeixin { color: white; background: #07c160; } .dim-logoWeixin svg { width: 19px; height: 19px; } .dim-logoFeishu { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); } -.dim-logoFeishu svg { width: 22px; height: 22px; } +.dim-logoFeishu svg { width: 28px; height: 28px; } .dim-logoDingtalk { color: white; background: #1677ff; } .dim-logoDingtalk svg { width: 24px; height: 24px; } .dim-logoQq { color: white; background: #1677ff; } @@ -40,17 +40,31 @@ const CSS = String.raw` .dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page { width: 100%; max-width: none; padding: 0 0 24px; } .dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; } .dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; justify-content: space-between; } -.dim-panel .bxf-headingTools .dim-scanButton, .dim-panel .dxw-tools .dim-scanButton, .dim-panel .ddt-tools .dim-scanButton { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; box-shadow: none; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; } +.dim-panel .dim-bindActions { min-width: 0; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; } +.dim-panel .bxf-headingTools .dim-scanButton, .dim-panel .dxw-tools .dim-scanButton, .dim-panel .ddt-tools .dim-scanButton { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 10px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; box-shadow: none; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; } .dim-panel .bxf-headingTools .dim-scanButton:hover:not(:disabled), .dim-panel .dxw-tools .dim-scanButton:hover:not(:disabled), .dim-panel .ddt-tools .dim-scanButton:hover:not(:disabled) { border-color: #0958d9; background: #0958d9; } +.dim-panel .dim-credentialButton { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 10px; border: 1px solid #86909c; border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 5%); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; } +.dim-panel .dim-actionIcon { width: 15px; height: 15px; flex: 0 0 15px; } +.dim-panel .dim-credentialButton:hover:not(:disabled) { border-color: #4e5969; background: var(--dsw-alias-fill-tertiary, #f7f8fa); } +.dim-panel .dim-credentialButton[aria-pressed="true"] { border-color: #4e5969; background: var(--dsw-alias-fill-secondary, #f2f3f5); box-shadow: inset 0 0 0 1px rgb(78 89 105 / 8%); } .dim-panel .bxf-headingTools .dim-onlineBadge, .dim-panel .dxw-tools .dim-onlineBadge, .dim-panel .ddt-tools .dim-onlineBadge { flex: none; min-height: 30px; display: inline-flex; align-items: center; gap: 0; padding: 0 11px; border: 0; border-radius: 999px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-fill-secondary, #f2f3f5); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; white-space: nowrap; } .dim-panel .dim-channelPage { width: 100%; max-width: none; display: flex; flex-direction: column; gap: 18px; padding: 0 0 24px; color: var(--dsw-alias-label-primary, #1f2329); box-sizing: border-box; } .dim-panel .dim-surfaceCard { position: relative; overflow: hidden; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-body, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 3%); } .dim-panel .dim-surfaceCard::before { display: none; } .dim-panel .dim-surfaceBody { padding: 24px; } +.dim-panel .dim-credentialPanel { display: grid; gap: 18px; padding: 20px; } +.dim-panel .dim-credentialTitle { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: 1.35; font-weight: 650; } +.dim-panel .dim-credentialForm { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 12px; } +.dim-panel .dim-credentialField { min-width: 0; display: grid; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; font-weight: 560; } +.dim-panel .dim-credentialField input { width: 100%; min-width: 0; height: 38px; padding: 0 11px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; transition: border-color .16s ease, box-shadow .16s ease; } +.dim-panel .dim-credentialField input:focus { border-color: #4e5969; box-shadow: 0 0 0 3px rgb(78 89 105 / 10%); } +.dim-panel .dim-credentialField input::placeholder { color: var(--dsw-alias-label-tertiary, #8f959e); font-family: inherit; } +.dim-panel .dim-credentialError, .dim-panel .dim-credentialActions { grid-column: 1 / -1; } +.dim-panel .dim-credentialError { margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; } +.dim-panel .dim-credentialActions { margin-top: 0; } .dim-panel .dim-listSection { display: flex; flex-direction: column; gap: 0; } .dim-panel .dim-listHeading { min-height: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 2px 0 9px; padding: 0; } .dim-panel .dim-listHeading h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: normal; font-weight: 650; } -.dim-panel .dim-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: normal; } .dim-panel .dim-botList { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; } .dim-panel .dim-loadingView { padding: 38px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; } .dim-panel .dim-loadingView h3 { margin: 0 0 7px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: normal; font-weight: 650; } @@ -101,7 +115,7 @@ const CSS = String.raw` .dim-panel .dim-confirm p { margin: 7px 0 0; color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.6; } .dim-panel .dim-cardFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-line-divider, #eef0f3); } .dim-panel .dim-cardSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; } -.dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin-top: 0; } +.dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin: 0 0 0 auto; } .dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; } .dim-panel .dim-cardActions .dim-cardAction:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-fill-tertiary, #f7f8fa); } .dim-panel .dim-cardActions .dim-cardAction[data-kind="danger"] { color: var(--dsw-alias-state-error-primary, #d54941); } @@ -128,6 +142,8 @@ const CSS = String.raw` .dim-panel .ddt-headingCopy { display: none; } .dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); } @container (max-width: 680px) { + .dim-panel .dim-credentialForm { grid-template-columns: minmax(0, 1fr); } + .dim-panel .dim-credentialError, .dim-panel .dim-credentialActions { grid-column: auto; } .dim-panel .dim-emptyView { min-height: 0; grid-template-columns: minmax(0, 1fr); } .dim-panel .dim-emptyBrand { display: none; } .dim-panel .dim-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; gap: 24px; } diff --git a/plugin-src/host/channels/dingtalk/rpc.mjs b/plugin-src/host/channels/dingtalk/rpc.mjs index ae6c241..57823ab 100644 --- a/plugin-src/host/channels/dingtalk/rpc.mjs +++ b/plugin-src/host/channels/dingtalk/rpc.mjs @@ -6,6 +6,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({ beginProvisioning: 'provision.begin', pollProvisioning: 'provision.poll', cancelProvisioning: 'provision.cancel', + bindCredentials: 'bot.bind-credentials', reconnectBot: 'bot.reconnect', deleteBot: 'bot.delete', approveSender: 'bot.sender.approve', @@ -38,6 +39,10 @@ function validId(value) { return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value); } +function validCredential(value, maxLength) { + return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength; +} + function payloadFailure(endpoint, payload) { if (!isRecord(payload)) return 'Payload must be an object.'; if (endpoint === DINGTALK_ENDPOINTS.status) { @@ -53,6 +58,13 @@ function payloadFailure(endpoint, payload) { ? null : `${endpoint} requires an attemptId.`; } + if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) { + return exactKeys(payload, ['clientId', 'clientSecret']) + && validCredential(payload.clientId, 256) + && validCredential(payload.clientSecret, 1024) + ? null + : 'bot.bind-credentials requires Client ID and Client Secret.'; + } if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) { return exactKeys(payload, ['botId']) && validId(payload.botId) ? null @@ -138,6 +150,7 @@ function assertController(controller) { 'startProvisioning', 'registrationStatus', 'cancelProvisioning', + 'bindCredentials', 'reconnectBot', 'deleteBot', 'approveSender', @@ -187,6 +200,8 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } = value = await controller.cancelProvisioning(payload.attemptId); if (!value) return badRequest('The provisioning attempt no longer exists.'); value = sanitizePublic(value); + } else if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) { + value = await publicStatus(await controller.bindCredentials(payload), cachedEncode); } else if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) { value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode); } else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) { diff --git a/plugin-src/host/channels/feishu/rpc.mjs b/plugin-src/host/channels/feishu/rpc.mjs index 46bd312..78098d5 100644 --- a/plugin-src/host/channels/feishu/rpc.mjs +++ b/plugin-src/host/channels/feishu/rpc.mjs @@ -59,6 +59,10 @@ function safeOpaqueId(value) { return typeof value === 'string' && SAFE_ID.test(value); } +function validCredential(value, maxLength) { + return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength; +} + function publicError(error) { if (!error || typeof error !== 'object') return null; const code = typeof error.code === 'string' && Object.hasOwn(PUBLIC_ERROR_MESSAGES, error.code) @@ -233,6 +237,13 @@ function validPayload(endpoint, payload) { } return null; } + if (endpoint === FEISHU_ENDPOINTS.bindCredentials) { + return hasOnlyKeys(payload, new Set(['appId', 'appSecret'])) + && validCredential(payload.appId, 256) + && validCredential(payload.appSecret, 1024) + ? null + : 'Credential binding requires App ID and App Secret.'; + } if (endpoint === FEISHU_ENDPOINTS.pollProvisioning || endpoint === FEISHU_ENDPOINTS.cancelProvisioning) { return hasOnlyKeys(payload, new Set(['attemptId'])) && safeOpaqueId(payload.attemptId) @@ -393,6 +404,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl } if (url) qrCache.delete(url); attemptQr.delete(payload.attemptId); value = { status: 'failed', message: 'Registration was cancelled.' }; + } else if (endpoint === FEISHU_ENDPOINTS.bindCredentials) { + if (typeof controller.bindCredentials !== 'function') { + throw new Error('Credential binding is unavailable'); + } + value = await toPublicFeishuStatus( + await controller.bindCredentials(payload), + { encodeQr: cachedEncodeQr }, + ); } else if (endpoint === FEISHU_ENDPOINTS.testConnection) { const current = await controller.status(); const alreadyConnected = current?.connected === true diff --git a/plugin-src/host/channels/qq/rpc.mjs b/plugin-src/host/channels/qq/rpc.mjs index c2212e4..58865df 100644 --- a/plugin-src/host/channels/qq/rpc.mjs +++ b/plugin-src/host/channels/qq/rpc.mjs @@ -6,6 +6,7 @@ export const QQ_ENDPOINTS = Object.freeze({ beginProvisioning: 'provision.begin', pollProvisioning: 'provision.poll', cancelProvisioning: 'provision.cancel', + bindCredentials: 'bot.bind-credentials', reconnectBot: 'bot.reconnect', deleteBot: 'bot.delete', }); @@ -27,6 +28,10 @@ function validId(value) { return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value); } +function validCredential(value, maxLength) { + return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength; +} + function payloadFailure(endpoint, payload) { if (!isRecord(payload)) return 'Payload must be an object.'; if (endpoint === QQ_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.'; @@ -38,6 +43,12 @@ function payloadFailure(endpoint, payload) { return exactKeys(payload, ['attemptId']) && validId(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; } + if (endpoint === QQ_ENDPOINTS.bindCredentials) { + return exactKeys(payload, ['appId', 'appSecret']) + && validCredential(payload.appId, 256) + && validCredential(payload.appSecret, 1024) + ? null : 'bot.bind-credentials requires AppID and AppSecret.'; + } if (endpoint === QQ_ENDPOINTS.reconnectBot) { return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.'; } @@ -76,7 +87,7 @@ async function publicStatus(status, encodeQr) { } export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { - for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) { + for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'bindCredentials', 'reconnectBot', 'deleteBot']) { if (typeof controller?.[method] !== 'function') throw new TypeError(`A complete QQ controller is required (${method})`); } const qrCache = new Map(); @@ -104,6 +115,8 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { value = await withEncodedQr(current, cachedEncode); } else if (endpoint === QQ_ENDPOINTS.cancelProvisioning) { value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId)); + } else if (endpoint === QQ_ENDPOINTS.bindCredentials) { + value = await publicStatus(await controller.bindCredentials(payload), cachedEncode); } else if (endpoint === QQ_ENDPOINTS.reconnectBot) { value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode); } else { diff --git a/plugin-src/host/channels/wecom/rpc.mjs b/plugin-src/host/channels/wecom/rpc.mjs index b0d24dd..9be4009 100644 --- a/plugin-src/host/channels/wecom/rpc.mjs +++ b/plugin-src/host/channels/wecom/rpc.mjs @@ -6,6 +6,7 @@ export const WECOM_ENDPOINTS = Object.freeze({ beginProvisioning: 'provision.begin', pollProvisioning: 'provision.poll', cancelProvisioning: 'provision.cancel', + bindCredentials: 'bot.bind-credentials', reconnectBot: 'bot.reconnect', deleteBot: 'bot.delete', }); @@ -27,6 +28,10 @@ function validId(value) { return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value); } +function validCredential(value, maxLength) { + return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength; +} + function payloadFailure(endpoint, payload) { if (!isRecord(payload)) return 'Payload must be an object.'; if (endpoint === WECOM_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.'; @@ -38,6 +43,12 @@ function payloadFailure(endpoint, payload) { return exactKeys(payload, ['attemptId']) && validId(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; } + if (endpoint === WECOM_ENDPOINTS.bindCredentials) { + return exactKeys(payload, ['botId', 'secret']) + && validCredential(payload.botId, 512) + && validCredential(payload.secret, 1024) + ? null : 'bot.bind-credentials requires Bot ID and Secret.'; + } if (endpoint === WECOM_ENDPOINTS.reconnectBot) { return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.'; } @@ -76,7 +87,7 @@ async function publicStatus(status, encodeQr) { } export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { - for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) { + for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'bindCredentials', 'reconnectBot', 'deleteBot']) { if (typeof controller?.[method] !== 'function') { throw new TypeError(`A complete Enterprise WeChat controller is required (${method})`); } @@ -109,6 +120,8 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {}) value = await withEncodedQr(current, cachedEncode); } else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) { value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId)); + } else if (endpoint === WECOM_ENDPOINTS.bindCredentials) { + value = await publicStatus(await controller.bindCredentials(payload), cachedEncode); } else if (endpoint === WECOM_ENDPOINTS.reconnectBot) { value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode); } else { diff --git a/src/channels/dingtalk/dingtalk-controller.mjs b/src/channels/dingtalk/dingtalk-controller.mjs index 8388e27..852ccfa 100644 --- a/src/channels/dingtalk/dingtalk-controller.mjs +++ b/src/channels/dingtalk/dingtalk-controller.mjs @@ -280,6 +280,50 @@ export class DingtalkController { } } + /** Binds one DingTalk application with an existing Client ID and Client Secret. */ + async bindCredentials({ clientId, clientSecret } = {}) { + if (this.#closed) throw new Error('dsh-dingtalk controller is closed'); + const normalizedClientId = cleanString(clientId); + const normalizedSecret = cleanString(clientSecret); + if (!normalizedClientId || !normalizedSecret) { + throw new TypeError('DingTalk Client ID and Client Secret are required'); + } + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + if (this.#closed) throw new Error('dsh-dingtalk controller is closed'); + const identity = deriveDingtalkBotIdentity(normalizedClientId); + await this.#withBotTransition(identity.botId, async () => { + if (this.#closed) throw abortError(); + const previousConfig = this.#configStore.getByClientId(normalizedClientId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined); + if (this.#closed) throw abortError(); + const config = { + botId: identity.botId, + clientId: normalizedClientId, + secretRef: identity.secretRef, + approvedSenders: previousConfig?.approvedSenders ?? [], + }; + await this.#credentials.set(identity.secretRef, normalizedSecret); + try { + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + try { + await this.#startRuntime(config, normalizedSecret); + this.#errors.delete(identity.botId); + } catch { + this.#errors.set( + identity.botId, + safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'), + ); + this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready'); + } + this.#touch(); + }); + return this.status(); + } + /** Polls one QR registration without exposing its device code or returned secret. */ async registrationStatus(attemptId) { const record = this.#attempts.get(attemptId); diff --git a/src/channels/feishu/message-utils.mjs b/src/channels/feishu/message-utils.mjs index 91ae484..3b30c57 100644 --- a/src/channels/feishu/message-utils.mjs +++ b/src/channels/feishu/message-utils.mjs @@ -45,6 +45,7 @@ export function isBotSender(event) { export function isAllowedSender(event, allowedOpenIds) { if (!allowedOpenIds || allowedOpenIds.size === 0) return false; + if (allowedOpenIds.has('*')) return true; const senderOpenId = event?.sender?.sender_id?.open_id; return typeof senderOpenId === 'string' && allowedOpenIds.has(senderOpenId); } diff --git a/src/channels/feishu/multi-bot-controller.mjs b/src/channels/feishu/multi-bot-controller.mjs index 669f02b..8094775 100644 --- a/src/channels/feishu/multi-bot-controller.mjs +++ b/src/channels/feishu/multi-bot-controller.mjs @@ -6,6 +6,7 @@ const ACTIVE_REGISTRATION_STATES = new Set([ 'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched', ]); const MUTABLE_REGISTRATION_STATES = new Set([...ACTIVE_REGISTRATION_STATES, 'saving']); +const ALL_VISIBLE_SENDERS = '*'; function idleConnection() { return { @@ -227,6 +228,75 @@ export class MultiBotDshFeishuController { }); } + async bindCredentials({ appId, appSecret, domain = 'feishu' } = {}) { + this.#assertOpen(); + const normalizedAppId = typeof appId === 'string' ? appId.trim() : ''; + const normalizedSecret = typeof appSecret === 'string' ? appSecret.trim() : ''; + const normalizedDomain = domain === 'lark' ? 'lark' : 'feishu'; + if (!normalizedAppId || !normalizedSecret) { + throw new TypeError('Feishu App ID and App Secret are required'); + } + + return this.#serializeConfig(async () => { + this.#assertOpen(); + const bot = await this.#verifyApp({ + appId: normalizedAppId, + appSecret: normalizedSecret, + domain: normalizedDomain, + }); + this.#assertOpen(); + const existing = this.#configStore.list().find( + (candidate) => candidate.appId === normalizedAppId, + ); + const botId = existing?.id ?? this.#createBotId(); + if (typeof botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(botId) + || (!existing && this.#configStore.getBot(botId))) { + throw new Error('Bot id generator returned an invalid or duplicate id'); + } + const secretRef = existing?.secretRef ?? secretRefFor(botId); + const previousSecret = await this.#credentials.resolve(secretRef).catch(() => undefined); + const config = { + ...existing, + id: botId, + appId: normalizedAppId, + secretRef, + ownerOpenIds: existing?.ownerOpenIds?.length + ? existing.ownerOpenIds + : [ALL_VISIBLE_SENDERS], + domain: normalizedDomain, + botName: bot.name, + botOpenId: bot.openId, + activated: bot.activated, + deletionPending: false, + connectedAt: new Date().toISOString(), + createdAt: existing?.createdAt ?? new Date().toISOString(), + }; + + await this.#credentials.set(secretRef, normalizedSecret); + let saved; + try { + saved = await this.#configStore.saveBot(config); + } catch (error) { + await this.#restoreCredential(secretRef, previousSecret); + throw error; + } + + await this.#withBotTransition(botId, async () => { + try { + await this.#startRuntime(saved, normalizedSecret); + this.#botErrors.delete(botId); + } catch { + this.#botErrors.set(botId, { + code: 'connection_failed', + message: '机器人已经绑定,但长连接未就绪,请点击重试。', + }); + } + }); + this.#touch(); + return this.status(botId); + }); + } + async reconnectBot(botId) { this.#assertOpen(); return this.#withBotTransition(botId, async () => { diff --git a/src/channels/qq/qq-bridge.mjs b/src/channels/qq/qq-bridge.mjs index a086663..e0da05c 100644 --- a/src/channels/qq/qq-bridge.mjs +++ b/src/channels/qq/qq-bridge.mjs @@ -87,7 +87,7 @@ export class QqHarnessBridge { this.#status.messagesReceived += 1; this.#status.lastMessageAt = new Date().toISOString(); - if (sender !== this.#ownerUserOpenid) { + if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) { this.#status.messagesRejected += 1; this.#status.lastRejectedAt = new Date().toISOString(); return; diff --git a/src/channels/qq/qq-controller.mjs b/src/channels/qq/qq-controller.mjs index 0126fdf..208e189 100644 --- a/src/channels/qq/qq-controller.mjs +++ b/src/channels/qq/qq-controller.mjs @@ -172,6 +172,48 @@ export class QqController { return publicAttempt(this.#attempts.get(attemptId)); } + async bindCredentials({ appId, appSecret } = {}) { + if (this.#closed) throw new Error('QQ controller is closed'); + const normalizedAppId = cleanString(appId); + const normalizedSecret = cleanString(appSecret); + if (!normalizedAppId || !normalizedSecret) { + throw new TypeError('QQ AppID and AppSecret are required'); + } + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + if (this.#closed) throw new Error('QQ controller is closed'); + const identity = deriveQqBotIdentity(normalizedAppId); + await this.#withBotTransition(identity.botId, async () => { + if (this.#closed) throw new Error('QQ controller is closed'); + const previousConfig = this.#configStore.getByAppId(normalizedAppId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined); + if (this.#closed) throw new Error('QQ controller is closed'); + const config = { + botId: identity.botId, + appId: normalizedAppId, + secretRef: identity.secretRef, + ownerUserOpenid: previousConfig?.ownerUserOpenid ?? '*', + createdAt: previousConfig?.createdAt ?? new Date().toISOString(), + connectedAt: new Date().toISOString(), + }; + await this.#credentials.set(identity.secretRef, normalizedSecret); + try { + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + try { + await this.#startRuntime(config, normalizedSecret); + this.#errors.delete(identity.botId); + } catch (error) { + this.#errors.set(identity.botId, safeError('connection-failed', 'QQ 机器人已绑定,消息连接暂未就绪。')); + this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error); + } + this.#touch(); + }); + return this.status(); + } + async cancelProvisioning(attemptId) { const record = this.#attempts.get(attemptId); if (!record) return null; @@ -281,6 +323,7 @@ export class QqController { if (this.#closed) return; this.#closed = true; if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + await Promise.allSettled([...this.#transitions.values()]); await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); } @@ -358,7 +401,9 @@ export class QqController { } async #startRuntime(config, appSecret) { + if (this.#closed) throw new Error('QQ controller is closed'); await this.#stopRuntime(config.botId); + if (this.#closed) throw new Error('QQ controller is closed'); const runtime = await this.#createRuntime({ botId: config.botId, config, appSecret }); if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') { throw new TypeError('createRuntime returned an invalid QQ runtime'); diff --git a/src/channels/qq/qq-runtime.mjs b/src/channels/qq/qq-runtime.mjs index 97ef707..3653de2 100644 --- a/src/channels/qq/qq-runtime.mjs +++ b/src/channels/qq/qq-runtime.mjs @@ -113,7 +113,8 @@ export class QqRuntime { }); bot.use?.(this.#typingMiddleware({ keepAlive: true, - predicate: (ctx) => ctx?.message?.senderId === this.#config.ownerUserOpenid, + predicate: (ctx) => this.#config.ownerUserOpenid === '*' + || ctx?.message?.senderId === this.#config.ownerUserOpenid, })); const controller = new AbortController(); diff --git a/src/channels/wecom/wecom-controller.mjs b/src/channels/wecom/wecom-controller.mjs index b5917e5..fb5e161 100644 --- a/src/channels/wecom/wecom-controller.mjs +++ b/src/channels/wecom/wecom-controller.mjs @@ -161,6 +161,50 @@ export class WecomController { return publicAttempt(record); } + async bindCredentials({ botId, secret } = {}) { + if (this.#closed) throw new Error('Enterprise WeChat controller is closed'); + const remoteBotId = cleanString(botId); + const normalizedSecret = cleanString(secret); + if (!remoteBotId || !normalizedSecret) { + throw new TypeError('Enterprise WeChat Bot ID and Secret are required'); + } + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + if (this.#closed) throw new Error('Enterprise WeChat controller is closed'); + const identity = deriveWecomBotIdentity(remoteBotId); + await this.#withBotTransition(identity.botId, async () => { + if (this.#closed) throw new Error('Enterprise WeChat controller is closed'); + const previousConfig = this.#configStore.getByRemoteBotId(remoteBotId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined); + if (this.#closed) throw new Error('Enterprise WeChat controller is closed'); + const config = { + botId: identity.botId, + remoteBotId, + secretRef: identity.secretRef, + createdAt: previousConfig?.createdAt ?? new Date().toISOString(), + connectedAt: new Date().toISOString(), + }; + await this.#credentials.set(identity.secretRef, normalizedSecret); + try { + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + try { + await this.#startRuntime(config, normalizedSecret); + this.#errors.delete(identity.botId); + } catch { + this.#errors.set( + identity.botId, + safeError('connection-failed', '企业微信机器人已绑定,消息连接暂未就绪。'), + ); + this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} credential connection failed`); + } + this.#touch(); + }); + return this.status(); + } + async cancelProvisioning(attemptId) { const record = this.#attempts.get(attemptId); if (!record) return null; @@ -268,6 +312,7 @@ export class WecomController { if (this.#closed) return; this.#closed = true; if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + await Promise.allSettled([...this.#transitions.values()]); await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); } @@ -368,7 +413,9 @@ export class WecomController { } async #startRuntime(config, secret) { + if (this.#closed) throw new Error('Enterprise WeChat controller is closed'); await this.#stopRuntime(config.botId); + if (this.#closed) throw new Error('Enterprise WeChat controller is closed'); const runtime = await this.#createRuntime({ botId: config.botId, config, secret }); if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') { throw new TypeError('createRuntime returned an invalid Enterprise WeChat runtime'); diff --git a/test/channels/dingtalk/client-api.test.mjs b/test/channels/dingtalk/client-api.test.mjs index 248d260..f345094 100644 --- a/test/channels/dingtalk/client-api.test.mjs +++ b/test/channels/dingtalk/client-api.test.mjs @@ -19,6 +19,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => { beginProvisioning: 'provision.begin', pollProvisioning: 'provision.poll', cancelProvisioning: 'provision.cancel', + bindCredentials: 'bot.bind-credentials', reconnectBot: 'bot.reconnect', deleteBot: 'bot.delete', }); diff --git a/test/channels/dingtalk/dingtalk-controller.test.mjs b/test/channels/dingtalk/dingtalk-controller.test.mjs index dea06e3..7edb464 100644 --- a/test/channels/dingtalk/dingtalk-controller.test.mjs +++ b/test/channels/dingtalk/dingtalk-controller.test.mjs @@ -148,6 +148,30 @@ test('successful QR poll stores secret then config then starts runtime without p await controller.close(); }); +test('manual DingTalk Client ID and Client Secret binding stores credentials off the public plane', async () => { + const events = []; + const credentials = credentialsFixture(events); + const configs = configFixture([], events); + const runtimes = runtimeFactory({ events }); + const controller = new DingtalkController({ + deviceAuth: successfulDeviceAuth(), + credentials: credentials.provider, + configStore: configs.store, + createRuntime: runtimes.createRuntime, + clock: () => 1_000, + }); + + const status = await controller.bindCredentials({ + clientId: 'manual-ding-client', + clientSecret: 'manual-ding-secret', + }); + assert.equal(status.totals.connected, 1); + assert.equal([...credentials.values.values()][0], 'manual-ding-secret'); + assert.equal(runtimes.runtimes[0].clientSecret, 'manual-ding-secret'); + assert.doesNotMatch(JSON.stringify(status), /manual-ding-client|manual-ding-secret|secretRef/); + await controller.close(); +}); + test('scanning the same client ID is idempotent and replaces its one runtime', async () => { const events = []; const credentials = credentialsFixture(events); diff --git a/test/channels/dingtalk/plugin-host.test.mjs b/test/channels/dingtalk/plugin-host.test.mjs index d421a24..1dda8c0 100644 --- a/test/channels/dingtalk/plugin-host.test.mjs +++ b/test/channels/dingtalk/plugin-host.test.mjs @@ -9,6 +9,7 @@ function controller() { startProvisioning() {}, registrationStatus() {}, cancelProvisioning() {}, + bindCredentials() {}, reconnectBot() {}, deleteBot() {}, approveSender() {}, diff --git a/test/channels/dingtalk/rpc.test.mjs b/test/channels/dingtalk/rpc.test.mjs index e7430b8..389c723 100644 --- a/test/channels/dingtalk/rpc.test.mjs +++ b/test/channels/dingtalk/rpc.test.mjs @@ -19,6 +19,7 @@ function controller(overrides = {}) { }), registrationStatus: async () => ({ attemptId: 'attempt_1', status: 'pending' }), cancelProvisioning: async () => ({ attemptId: 'attempt_1', status: 'cancelled' }), + bindCredentials: async () => ({ bots: [] }), reconnectBot: async () => ({ bots: [] }), deleteBot: async () => ({ bots: [] }), approveSender: async () => ({ bots: [] }), @@ -76,6 +77,24 @@ test('RPC validates mutating requests before invoking the controller', async () assert.equal(calls, 0); }); +test('credential RPC accepts Client ID fields while keeping Client Secret host-only', async () => { + let received; + const handler = createDingtalkRpcHandler(controller({ + bindCredentials: async (payload) => { + received = payload; + return { bots: [], clientSecret: payload.clientSecret }; + }, + })); + + const result = await handler(DINGTALK_ENDPOINTS.bindCredentials, { + clientId: 'manual-client', clientSecret: 'manual-secret', + }); + assert.equal(result.ok, true); + assert.deepEqual(received, { clientId: 'manual-client', clientSecret: 'manual-secret' }); + assert.doesNotMatch(JSON.stringify(result), /manual-secret|clientSecret/); + assert.equal((await handler(DINGTALK_ENDPOINTS.bindCredentials, { clientId: 'manual-client' })).ok, false); +}); + test('RPC is registered for loopback clients only', () => { const registrations = []; const dispose = () => {}; diff --git a/test/channels/feishu/message-utils.test.mjs b/test/channels/feishu/message-utils.test.mjs index 12280dc..9415dc7 100644 --- a/test/channels/feishu/message-utils.test.mjs +++ b/test/channels/feishu/message-utils.test.mjs @@ -47,4 +47,5 @@ test('isAllowedSender enforces an open-id allowlist', () => { assert.equal(isAllowedSender(event, new Set()), false); assert.equal(isAllowedSender(event, new Set(['ou_allowed'])), true); assert.equal(isAllowedSender(event, new Set(['ou_other'])), false); + assert.equal(isAllowedSender(event, new Set(['*'])), true); }); diff --git a/test/channels/feishu/multi-bot-controller.test.mjs b/test/channels/feishu/multi-bot-controller.test.mjs index c5469c3..2b3033e 100644 --- a/test/channels/feishu/multi-bot-controller.test.mjs +++ b/test/channels/feishu/multi-bot-controller.test.mjs @@ -143,6 +143,23 @@ async function completeScan(fx, result) { return fx.controller.registrationStatus(attemptId); } +test('manual Feishu credentials are verified, stored host-side, and use app visibility for access', async () => { + const fx = fixture({ createBotIds: ['bot_manual'] }); + + const status = await fx.controller.bindCredentials({ + appId: 'cli_manual', + appSecret: 'manual-private-secret', + }); + + assert.equal(status.totals.connected, 1); + assert.equal(fx.configStore.bots.length, 1); + assert.deepEqual(fx.configStore.bots[0].ownerOpenIds, ['*']); + assert.equal(fx.values.get(fx.configStore.bots[0].secretRef), 'manual-private-secret'); + assert.equal(fx.runtimes.get('bot_manual')[0].appSecret, 'manual-private-secret'); + assert.doesNotMatch(JSON.stringify(status), /manual-private-secret|ownerOpenIds|secretRef/); + await fx.controller.close(); +}); + test('initialization isolates failures and starts every bot with available credentials', async () => { const missing = bot('bot_missing', 'missing'); const healthy = bot('bot_healthy', 'healthy'); diff --git a/test/channels/feishu/plugin-host.test.mjs b/test/channels/feishu/plugin-host.test.mjs index e73119b..3a53553 100644 --- a/test/channels/feishu/plugin-host.test.mjs +++ b/test/channels/feishu/plugin-host.test.mjs @@ -131,6 +131,10 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => { return current; }, reconnect: async () => { calls.push('test'); return current; }, + bindCredentials: async ({ appId, appSecret }) => { + calls.push(`bind:${appId}:${appSecret}`); + return current; + }, disconnect: async () => { calls.push('disconnect'); return status(); }, }; const fx = await rpcFixture(controller); @@ -155,6 +159,14 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => { const tested = await fx.registration.handler(FEISHU_ENDPOINTS.testConnection, {}, signal()); assert.equal(tested.ok, true); + const bound = await fx.registration.handler( + FEISHU_ENDPOINTS.bindCredentials, + { appId: 'cli_manual', appSecret: 'manual-private-secret' }, + signal(), + ); + assert.equal(bound.ok, true); + assert.doesNotMatch(JSON.stringify(bound), /manual-private-secret|appSecret/); + const cancelled = await fx.registration.handler( FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: '7' }, @@ -169,7 +181,9 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => { signal(), ); assert.equal(disconnected.ok, true); - assert.deepEqual(calls, ['begin', 'test', 'cancel', 'disconnect']); + assert.deepEqual(calls, [ + 'begin', 'test', 'bind:cli_manual:manual-private-secret', 'cancel', 'disconnect', + ]); const attemptedSecret = await fx.registration.handler( FEISHU_ENDPOINTS.beginProvisioning, diff --git a/test/channels/qq/bridge.test.mjs b/test/channels/qq/bridge.test.mjs index 9f49acc..9fcc3f1 100644 --- a/test/channels/qq/bridge.test.mjs +++ b/test/channels/qq/bridge.test.mjs @@ -79,3 +79,25 @@ test('QQ bridge accepts only the scanner and requires an at-message event in gro assert.equal(asks, 0); assert.equal(bridge.status.messagesRejected, 1); }); + +test('QQ credential-bound bots accept senders within the platform visibility scope', async () => { + let asks = 0; + const bridge = new QqHarnessBridge({ + bot: { sendText: async () => {} }, + ownerUserOpenid: '*', + harness: { + sessionExists: async () => true, + ask: async () => { asks += 1; return 'ok'; }, + }, + state: { + hasSeen: () => false, + markSeen: async () => {}, + sessionFor: () => 'session', + setSession: async () => {}, + clearSession: async () => {}, + }, + }); + await bridge.accept(message({ messageId: 'visible', senderId: 'visible-user' })); + assert.equal(asks, 1); + assert.equal(bridge.status.messagesRejected, 0); +}); diff --git a/test/channels/qq/client-ui.test.mjs b/test/channels/qq/client-ui.test.mjs index cfd3593..6c28601 100644 --- a/test/channels/qq/client-ui.test.mjs +++ b/test/channels/qq/client-ui.test.mjs @@ -12,7 +12,8 @@ test('QQ settings uses the shared compact channel toolbar', () => { })); assert.match(markup, /class="ddt-page dqq-page dim-channelPage"/); assert.match(markup, /class="ddt-button dim-scanButton"/); - assert.match(markup, />扫码绑定QQ机器人 { assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2); assert.match(markup, />消息通道<[^]*>最近检查检查连接<[^]*>移除接入连接失败,请检查凭据 { + const values = new Map(); + const configs = []; + let runtimeArgs; + const controller = new QqController({ + qrAuth: { start() { return () => {}; } }, + credentials: { + resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined, + set: async (ref, value) => values.set(ref, value), + unset: async (ref) => values.delete(ref), + }, + configStore: { + list: () => [...configs], + get: (id) => configs.find((value) => value.botId === id) ?? null, + getByAppId: (id) => configs.find((value) => value.appId === id) ?? null, + save: async (value) => { configs.splice(0, configs.length, value); }, + remove: async () => null, + }, + createRuntime: async (args) => { + runtimeArgs = args; + return { + status: { ready: true, qqConnectionState: 'connected', harnessReachable: true }, + start: async () => {}, stop: async () => {}, + }; + }, + }); + + const status = await controller.bindCredentials({ appId: 'manual-app', appSecret: 'manual-secret' }); + assert.equal(status.totals.connected, 1); + assert.equal(configs[0].ownerUserOpenid, '*'); + assert.equal(values.get(configs[0].secretRef), 'manual-secret'); + assert.equal(runtimeArgs.appSecret, 'manual-secret'); + assert.doesNotMatch(JSON.stringify(status), /manual-secret|ownerUserOpenid|secretRef/); + await controller.close(); +}); + test('QQ RPC turns the host-only QR URL into an image and strips credential fields', async () => { const handler = createQqRpcHandler({ status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }), @@ -63,6 +99,7 @@ test('QQ RPC turns the host-only QR URL into an image and strips credential fiel }), registrationStatus: () => null, cancelProvisioning: async () => ({}), + bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }), reconnectBot: async () => ({}), deleteBot: async () => ({}), }, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' }); @@ -71,3 +108,23 @@ test('QQ RPC turns the host-only QR URL into an image and strips credential fiel assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA=='); assert.doesNotMatch(JSON.stringify(result), /q\.qq\.com|never-public|ownerUserOpenid|appSecret/); }); + +test('QQ credential RPC validates the pair and never returns AppSecret', async () => { + let received; + const handler = createQqRpcHandler({ + status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }), + startProvisioning: async () => ({}), registrationStatus: () => null, + cancelProvisioning: async () => ({}), reconnectBot: async () => ({}), deleteBot: async () => ({}), + bindCredentials: async (payload) => { + received = payload; + return { bots: [], totals: { configured: 0, connected: 0 }, appSecret: payload.appSecret }; + }, + }); + const result = await handler(QQ_ENDPOINTS.bindCredentials, { + appId: 'manual-app', appSecret: 'manual-secret', + }); + assert.equal(result.ok, true); + assert.deepEqual(received, { appId: 'manual-app', appSecret: 'manual-secret' }); + assert.doesNotMatch(JSON.stringify(result), /manual-secret|appSecret/); + assert.equal((await handler(QQ_ENDPOINTS.bindCredentials, { appId: 'manual-app' })).ok, false); +}); diff --git a/test/channels/wecom/client-ui.test.mjs b/test/channels/wecom/client-ui.test.mjs index e361fa0..7ac0c73 100644 --- a/test/channels/wecom/client-ui.test.mjs +++ b/test/channels/wecom/client-ui.test.mjs @@ -15,7 +15,8 @@ test('Enterprise WeChat settings uses the shared compact channel toolbar', () => })); assert.match(markup, /class="ddt-page dwecom-page dim-channelPage"/); assert.match(markup, /class="ddt-button dim-scanButton"/); - assert.match(markup, />扫码绑定企业微信机器人消息通道<[^]*>最近检查检查连接<[^]*>移除接入 { + const values = new Map(); + const configs = []; + let runtimeArgs; + const controller = new WecomController({ + qrAuth: { start: async () => ({}), poll: async () => ({ status: 'waiting' }) }, + credentials: { + resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined, + set: async (ref, value) => values.set(ref, value), + unset: async (ref) => values.delete(ref), + }, + configStore: { + list: () => [...configs], + get: (id) => configs.find((value) => value.botId === id) ?? null, + getByRemoteBotId: (id) => configs.find((value) => value.remoteBotId === id) ?? null, + save: async (value) => { configs.splice(0, configs.length, value); }, + remove: async () => null, + }, + createRuntime: async (args) => { + runtimeArgs = args; + return { + status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true }, + start: async () => {}, stop: async () => {}, + }; + }, + }); + const status = await controller.bindCredentials({ botId: 'remote-manual', secret: 'manual-secret' }); + assert.equal(status.totals.connected, 1); + assert.equal(values.get(configs[0].secretRef), 'manual-secret'); + assert.equal(runtimeArgs.secret, 'manual-secret'); + assert.doesNotMatch(JSON.stringify(status), /manual-secret|remote-manual|secretRef/); + await controller.close(); +}); + test('Enterprise WeChat RPC encodes the QR on Host and strips every authorization field', async () => { const handler = createWecomRpcHandler({ status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }), @@ -66,6 +100,7 @@ test('Enterprise WeChat RPC encodes the QR on Host and strips every authorizatio }), registrationStatus: async () => null, cancelProvisioning: async () => ({}), + bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }), reconnectBot: async () => ({}), deleteBot: async () => ({}), }, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' }); @@ -74,3 +109,23 @@ test('Enterprise WeChat RPC encodes the QR on Host and strips every authorizatio assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA=='); assert.doesNotMatch(JSON.stringify(result), /work\.weixin|never-public|remoteBotId|scode|secret/); }); + +test('Enterprise WeChat credential RPC accepts official Bot ID fields and redacts Secret', async () => { + let received; + const handler = createWecomRpcHandler({ + status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }), + startProvisioning: async () => ({}), registrationStatus: async () => null, + cancelProvisioning: async () => ({}), reconnectBot: async () => ({}), deleteBot: async () => ({}), + bindCredentials: async (payload) => { + received = payload; + return { bots: [], totals: { configured: 0, connected: 0 }, secret: payload.secret }; + }, + }); + const result = await handler(WECOM_ENDPOINTS.bindCredentials, { + botId: 'remote-manual', secret: 'manual-secret', + }); + assert.equal(result.ok, true); + assert.deepEqual(received, { botId: 'remote-manual', secret: 'manual-secret' }); + assert.doesNotMatch(JSON.stringify(result), /manual-secret|"secret"/); + assert.equal((await handler(WECOM_ENDPOINTS.bindCredentials, { botId: 'remote-manual' })).ok, false); +}); diff --git a/test/client-ui.test.mjs b/test/client-ui.test.mjs index 849480a..c0a392b 100644 --- a/test/client-ui.test.mjs +++ b/test/client-ui.test.mjs @@ -6,6 +6,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { IMSettingsTab } from '../plugin-src/client/index.js'; +import { CredentialBindingPanel } from '../plugin-src/client/credential-binding.js'; import { DINGTALK_ENDPOINTS } from '../plugin-src/client/channels/dingtalk/api.js'; import { AccountCard as DingtalkAccountCard, @@ -23,6 +24,7 @@ import { AccountCard as WecomAccountCard, WecomSettingsTab, } from '../plugin-src/client/channels/wecom/index.js'; +import { QqSettingsTab } from '../plugin-src/client/channels/qq/index.js'; const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url); const FEISHU_STYLES_URL = new URL( @@ -58,8 +60,13 @@ const WECOM_SOURCE_URL = new URL( '../plugin-src/client/channels/wecom/index.js', import.meta.url, ); +const QQ_SOURCE_URL = new URL( + '../plugin-src/client/channels/qq/index.js', + import.meta.url, +); -test('IM settings renders five compact logo channel tabs without enable switches', () => { +test('IM settings renders five compact logo channel tabs without enable switches', async () => { + const styles = await readFile(STYLES_URL, 'utf8'); const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, { feishuRpcCall: async () => ({ ok: true, value: {} }), weixinRpcCall: async () => ({ ok: true, value: {} }), @@ -81,6 +88,7 @@ test('IM settings renders five compact logo channel tabs without enable switches assert.match(markup, /dim-logoDingtalk/); assert.match(markup, /dim-logoWecom/); assert.match(markup, /dim-logoQq/); + assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/); assert.equal((markup.match(/role="tab"/g) ?? []).length, 5); assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1); assert.doesNotMatch(markup, /role="switch"|type="checkbox"/); @@ -130,6 +138,7 @@ test('Feishu bot cards place the application identifier under the bot name', asy assert.match(markup, /class="bxf-healthPill dim-botHealth"/); assert.match(markup, /]*aria-label="检查连接今天是牢梁"[^>]*>检查连接<\/span><\/button>/); assert.match(markup, /class="bxf-connectedFooter dim-cardFooter"/); + assert.doesNotMatch(markup, /dim-cardSummary|长连接运行正常/); assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2); assert.doesNotMatch(markup, /连接状态:|bxf-divider/); assert.doesNotMatch(markup, /custom-bot-avatar/); @@ -145,13 +154,60 @@ test('Feishu keeps its heading controls on one row without a plus icon', async ( rpcCall: async () => ({ ok: true, value: {} }), })); - assert.match(markup, /class="bxf-button bxf-bindButton dim-scanButton"[^>]*>扫码绑定机器人<\/span><\/button>/); + assert.match(markup, /aria-label="扫码接入飞书机器人"/); + assert.match(markup, /class="dim-actionIcon"[^]*扫码接入机器人<\/span>/); assert.doesNotMatch(markup, />添加机器人 { + const settings = [ + ['飞书', FeishuSettingsTab], + ['QQ', QqSettingsTab], + ['钉钉', DingtalkSettingsTab], + ['企业微信', WecomSettingsTab], + ]; + for (const [channel, Component] of settings) { + const markup = renderToStaticMarkup(React.createElement(Component, { + rpcCall: async () => ({ ok: true, value: {} }), + })); + const scanIndex = markup.indexOf('dim-scanButton'); + const credentialIndex = markup.indexOf('dim-credentialButton'); + assert.ok(scanIndex >= 0, `${channel} should render a QR button`); + assert.ok(credentialIndex > scanIndex, `${channel} should place credential binding after QR binding`); + assert.match(markup, /data-kind="credential"/); + const credentialMarkup = markup.slice(credentialIndex, markup.indexOf('', credentialIndex)); + assert.match(credentialMarkup, /dim-actionIcon/); + assert.match(credentialMarkup, /手动接入/); + } + + const styles = await readFile(STYLES_URL, 'utf8'); + assert.match(styles, /\.dim-panel \.dim-bindActions \{[^}]*flex-wrap: nowrap;/); + assert.match(styles, /\.dim-panel \.dim-credentialButton \{[^}]*border: 1px solid #86909c;[^}]*background: var\(--dsw-alias-bg-body, #fff\)/); + assert.match(styles, /\.dim-panel \.dim-actionIcon \{[^}]*flex: 0 0 15px;/); + assert.doesNotMatch(styles, /\.dim-panel \.dim-credentialPanel \{[^}]*border-left:/); +}); + +test('credential form stays compact while using a protected password input', () => { + const markup = renderToStaticMarkup(React.createElement(CredentialBindingPanel, { + channel: '企业微信', + identityLabel: 'Bot ID', + identityPlaceholder: '填写 Bot ID', + secretLabel: 'Secret', + secretPlaceholder: '填写 Secret', + onSubmit() {}, + onCancel() {}, + })); + assert.match(markup, />Bot ID手动接入企业微信机器人 { const [imStyles, feishuStyles, weixinStyles, dingtalkStyles, wecomStyles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([ readFile(STYLES_URL, 'utf8'), @@ -179,10 +235,10 @@ test('scan actions align left while online totals align right in every channel', const weixinHeading = headingSource(weixinSource); const dingtalkHeading = headingSource(dingtalkSource); const wecomHeading = headingSource(wecomSource); - assert.ok(feishuHeading.indexOf('扫码绑定机器人') < feishuHeading.indexOf('bxf-totalBadge')); - assert.ok(weixinHeading.indexOf('扫码绑定微信') < weixinHeading.indexOf('dxw-badge')); - assert.ok(dingtalkHeading.indexOf('扫码接入钉钉') < dingtalkHeading.indexOf('ddt-badge')); - assert.ok(wecomHeading.indexOf('扫码绑定企业微信机器人') < wecomHeading.indexOf('ddt-badge')); + assert.ok(feishuHeading.indexOf('扫码接入机器人') < feishuHeading.indexOf('bxf-totalBadge')); + assert.ok(weixinHeading.indexOf('扫码接入机器人') < weixinHeading.indexOf('dxw-badge')); + assert.ok(dingtalkHeading.indexOf('扫码接入机器人') < dingtalkHeading.indexOf('ddt-badge')); + assert.ok(wecomHeading.indexOf('扫码接入机器人') < wecomHeading.indexOf('ddt-badge')); for (const heading of [feishuHeading, weixinHeading, dingtalkHeading, wecomHeading]) { assert.match(heading, /dim-scanButton/); @@ -205,6 +261,20 @@ test('channel headings omit the redundant local credential badge', () => { } }); +test('bot list headings omit the total already shown by the online badge', async () => { + const sources = await Promise.all([ + FEISHU_SOURCE_URL, + WEIXIN_SOURCE_URL, + DINGTALK_CLIENT_SOURCE_URL, + WECOM_SOURCE_URL, + QQ_SOURCE_URL, + ].map((url) => readFile(url, 'utf8'))); + + for (const source of sources) { + assert.doesNotMatch(source, /length} 个/); + } +}); + test('all channel settings states use the DingTalk page treatment', async () => { const [styles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([ readFile(STYLES_URL, 'utf8'), @@ -277,6 +347,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => { assert.match(accountMarkup, /class="dxw-avatar dim-botAvatar"[^]*data-im-channel-logo="weixin"/); assert.match(accountMarkup, /class="dxw-health dim-botHealth"/); assert.match(accountMarkup, /class="dxw-accountFooter dim-cardFooter"/); + assert.doesNotMatch(accountMarkup, /dim-cardSummary|微信消息长轮询运行正常/); assert.equal((accountMarkup.match(/dim-cardAction(?: |")/g) ?? []).length, 2); assert.equal((accountMarkup.match(/class="dxw-metric dim-botMetric"/g) ?? []).length, 2); assert.doesNotMatch(accountMarkup, /收到 \/ 回复/); @@ -316,6 +387,7 @@ test('DingTalk bot cards omit the redundant received and replied metric', () => assert.match(markup, /class="ddt-health dim-botHealth"/); assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2); assert.match(markup, /class="ddt-accountFooter dim-cardFooter"/); + assert.doesNotMatch(markup, /dim-cardSummary|Stream 长连接运行正常/); assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2); assert.match(markup, />消息通道<[^]*>最近检查