diff --git a/README.md b/README.md index f52cb2a..b418a37 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - 飞书:扫码创建并绑定机器人,使用长连接收发消息; - 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息; -- 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收发消息。 +- 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。 其他 IM 平台可继续按同一渠道适配器结构接入。 @@ -26,7 +26,7 @@ npx -y github:xmanrui/dsh-im install 重启 `dsh web`,然后打开「设置 → 插件 → IM机器人」。安装器会用 `dsh-im` 替换 profile 中直接安装的 `dsh-feishu`、`dsh-weixin` 和 `dsh-dingtalk`,但不删除任何渠道数据;原有渠道凭据和扫码绑定会继续使用。 -钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。钉钉当前可能在该官方授权页显示 OpenClaw 品牌;扫码后的机器人连接、凭据和消息均由 DeepSeek Harness 管理。由于扫码结果不包含扫码人的 staff ID,首次私聊后还需要在本机页面批准该使用者,未批准消息不会进入 Harness。 +钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。钉钉当前可能在该官方授权页显示 OpenClaw 品牌;扫码后的机器人连接、凭据和消息均由 DeepSeek Harness 管理。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。 ## 设计 @@ -61,7 +61,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one se - Feishu: create and bind a bot by scanning a QR code, 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, then send and receive messages through DingTalk Stream. +- DingTalk: create and authorize a bot by scanning a QR code, receive messages through DingTalk Stream, and stream Harness replies through AI Cards. Other IM platforms can be added through the same channel-adapter structure. @@ -73,7 +73,7 @@ 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. That DingTalk-hosted page may currently display OpenClaw branding; the resulting connection, credentials, and messages are managed by DeepSeek Harness. Because the scan result does not identify the scanning user, send the bot a direct message and approve that sender locally before the message can enter Harness. +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. That DingTalk-hosted page may currently display OpenClaw branding; the resulting connection, credentials, and messages are managed by DeepSeek Harness. 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. ## Design diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 98291c4..825cef9 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -This package composes [`@xmanrui/dsh-feishu`](https://github.com/xmanrui/dsh-feishu) at commit `aad650feabadd511241aa58b236d64273d5e397f`, [`@xmanrui/dsh-weixin`](https://github.com/xmanrui/dsh-weixin) at commit `76d076771b2c84fb4c5598c2344d486695eda080`, and [`@xmanrui/dsh-dingtalk`](https://github.com/xmanrui/dsh-dingtalk) at commit `e21177c3ba845f42d8f72f68b63cce67fe1a1d55`, all under the MIT License. +This package composes [`@xmanrui/dsh-feishu`](https://github.com/xmanrui/dsh-feishu) at commit `aad650feabadd511241aa58b236d64273d5e397f`, [`@xmanrui/dsh-weixin`](https://github.com/xmanrui/dsh-weixin) at commit `76d076771b2c84fb4c5598c2344d486695eda080`, and [`@xmanrui/dsh-dingtalk`](https://github.com/xmanrui/dsh-dingtalk) at commit `05f5a319b52ef7f0952baf5d603ebc144657262a`, all under the MIT License. The channel client components are adapted from those projects and preserve their security model: secrets remain in the DeepSeek Harness Host credential provider and never enter the browser-facing RPC response. diff --git a/docs/images/imbot.png b/docs/images/imbot.png index cf9bfac..2a79dae 100644 Binary files a/docs/images/imbot.png and b/docs/images/imbot.png differ diff --git a/lib/client.js b/lib/client.js index c5cf0b0..ec02abc 100644 --- a/lib/client.js +++ b/lib/client.js @@ -40,7 +40,7 @@ __export(index_exports, { name: () => name }); module.exports = __toCommonJS(index_exports); -var React4 = __toESM(require("react"), 1); +var React5 = __toESM(require("react"), 1); // node_modules/@xmanrui/dsh-dingtalk/plugin-src/client/api.js var DINGTALK_RPC_CHANNEL = "/dingtalk"; @@ -50,9 +50,7 @@ var DINGTALK_ENDPOINTS = Object.freeze({ pollProvisioning: "provision.poll", cancelProvisioning: "provision.cancel", reconnectBot: "bot.reconnect", - deleteBot: "bot.delete", - approveSender: "bot.sender.approve", - revokeSender: "bot.sender.revoke" + deleteBot: "bot.delete" }); var ACCOUNT_STATES = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); var SNAPSHOT_STATES = /* @__PURE__ */ new Set(["disconnected", "offline", "provisioning", "connected", "degraded"]); @@ -157,54 +155,6 @@ function normalizeProvisioning(value, now = Date.now()) { if (error) result.error = error; return result; } -function normalizeConversationType(value) { - if (value === "group" || value === "2") return "group"; - return "direct"; -} -function normalizePendingSender(value) { - if (!isRecord(value)) return void 0; - const requestId = opaqueId(value.requestId); - if (!requestId) return void 0; - return { - requestId, - displayName: optionalString(value.displayName, 80) ?? "\u5F85\u6279\u51C6\u4F7F\u7528\u8005", - senderIdMasked: optionalString(value.senderIdMasked, 120) ?? "\u8EAB\u4EFD\u5DF2\u9690\u85CF", - conversationType: normalizeConversationType(value.conversationType), - requestedAt: timestamp(value.requestedAt) - }; -} -function normalizeApprovedSender(value) { - if (!isRecord(value)) return void 0; - const senderKey = opaqueId(value.senderKey); - if (!senderKey) return void 0; - return { - senderKey, - displayName: optionalString(value.displayName, 80) ?? "\u5DF2\u6279\u51C6\u4F7F\u7528\u8005", - senderIdMasked: optionalString(value.senderIdMasked, 120) ?? "\u8EAB\u4EFD\u5DF2\u9690\u85CF", - approvedAt: timestamp(value.approvedAt) - }; -} -function uniqueBy(items, key) { - const seen = /* @__PURE__ */ new Set(); - return items.filter((item) => { - if (!item || seen.has(item[key])) return false; - seen.add(item[key]); - return true; - }); -} -function normalizeSenders(value) { - const source = isRecord(value) ? value : {}; - return { - pending: uniqueBy( - (Array.isArray(source.pending) ? source.pending : []).map(normalizePendingSender), - "requestId" - ), - approved: uniqueBy( - (Array.isArray(source.approved) ? source.approved : []).map(normalizeApprovedSender), - "senderKey" - ) - }; -} function normalizeBot(value) { if (!isRecord(value)) return void 0; const botId = opaqueId(value.botId); @@ -215,7 +165,6 @@ function normalizeBot(value) { const state = connected ? "connected" : reportedState === "connected" ? "connecting" : reportedState; const health = isRecord(value.health) ? value.health : {}; const stats = isRecord(value.stats) ? value.stats : {}; - const senderSource = value.senders ?? value.senderPolicy ?? value.access; return { botId, state, @@ -235,7 +184,6 @@ function normalizeBot(value) { messagesReceived: nonNegativeInteger(stats.messagesReceived), messagesReplied: nonNegativeInteger(stats.messagesReplied) }, - senders: normalizeSenders(senderSource), error: normalizeError(value.error, "DINGTALK_ACCOUNT_ERROR", "\u9489\u9489\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA") ?? null }; } @@ -257,8 +205,7 @@ function normalizeSnapshot(value) { bots, totals: { configured: bots.length, - connected: bots.filter((bot) => bot.connected).length, - pendingApproval: bots.reduce((total, bot) => total + bot.senders.pending.length, 0) + connected: bots.filter((bot) => bot.connected).length }, provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null }; @@ -307,7 +254,6 @@ var CSS = String.raw` .ddt-tools, .ddt-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; } .ddt-tools { justify-content: flex-end; } .ddt-badge { min-height: 30px; display: inline-flex; align-items: center; gap: 7px; padding: 0 11px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-fill-secondary, #f2f3f5); font-size: 12px; white-space: nowrap; } -.ddt-badge[data-tone="attention"] { color: #ad6800; background: #fff7e6; } .ddt-dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #aeb3bb; } .ddt-dot[data-tone="success"] { background: var(--ddt-success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ddt-success) 14%, transparent); } .ddt-dot[data-tone="warning"] { background: var(--ddt-warning); } @@ -368,23 +314,10 @@ var CSS = String.raw` .ddt-accountIdentity h3 { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } .ddt-accountIdentity p { margin-top: 4px; color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; } .ddt-health { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; } -.ddt-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 20px 0; } -.ddt-metric { padding: 12px 14px; border-radius: 9px; background: var(--dsw-alias-fill-tertiary, #f7f8fa); } +.ddt-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 20px 0; } +.ddt-metric { min-width: 0; padding: 12px; border-radius: 9px; background: var(--dsw-alias-fill-tertiary, #f7f8fa); } .ddt-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; } .ddt-metric dd { overflow: hidden; margin: 5px 0 0; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } -.ddt-access { margin-top: 18px; padding: 15px 16px; border: 1px solid var(--dsw-alias-line-divider, #eef0f3); border-radius: 11px; background: color-mix(in srgb, var(--ddt-accent-wash) 35%, var(--dsw-alias-bg-body, #fff)); } -.ddt-accessHeader { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } -.ddt-accessHeader h4 { font-size: 13px; } -.ddt-accessHeader p { margin-top: 4px; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 1.5; } -.ddt-accessCount { flex: none; padding: 3px 8px; border-radius: 99px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-fill-secondary, #f2f3f5); font-size: 11px; } -.ddt-accessCount[data-pending="true"] { color: #ad6800; background: #fff1d6; } -.ddt-senderList { display: grid; gap: 8px; margin: 12px 0 0; padding: 0; list-style: none; } -.ddt-sender { min-height: 42px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-bg-body, #fff); } -.ddt-senderIdentity { min-width: 0; } -.ddt-senderIdentity strong { display: block; overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } -.ddt-senderIdentity span { display: block; overflow: hidden; margin-top: 3px; color: var(--dsw-alias-label-tertiary, #8f959e); font: 10px ui-monospace, SFMono-Regular, monospace; text-overflow: ellipsis; white-space: nowrap; } -.ddt-approvedLabel { margin-top: 13px; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; font-weight: 600; } -.ddt-noSenders { margin-top: 11px !important; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; } .ddt-accountFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-line-divider, #eef0f3); } .ddt-summary { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; } .ddt-confirm { padding: 18px 24px; border-top: 1px solid color-mix(in srgb, var(--ddt-error) 25%, transparent); background: color-mix(in srgb, var(--ddt-error) 5%, transparent); } @@ -394,12 +327,17 @@ var CSS = String.raw` .ddt-visuallyHidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } @keyframes ddt-spin { to { transform: rotate(360deg); } } @container (max-width: 680px) { + .ddt-heading { flex-direction: column; align-items: stretch; } + .ddt-tools { width: 100%; flex-wrap: nowrap; gap: 6px; } + .ddt-tools .ddt-badge { min-height: 34px; padding-inline: 8px; } + .ddt-tools .ddt-button { flex: none; padding-inline: 10px; white-space: nowrap; } .ddt-empty { grid-template-columns: minmax(0, 1fr); } .ddt-brandMark { display: none; } .ddt-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; gap: 24px; } .ddt-qrColumn { width: 100%; min-width: 0; } .ddt-qrCopy { width: 100%; } - .ddt-metrics { grid-template-columns: minmax(0, 1fr); } + .ddt-metrics { gap: 8px; } + .ddt-metric { padding: 10px; } } @media (max-width: 720px) { .ddt-heading, .ddt-accountTop, .ddt-accountFooter { flex-direction: column; align-items: stretch; } @@ -409,9 +347,7 @@ var CSS = String.raw` .ddt-brandMark { display: none; } .ddt-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; } .ddt-qrCopy { width: 100%; } - .ddt-metrics { grid-template-columns: minmax(0, 1fr); } .ddt-cardBody { padding: 20px; } - .ddt-sender { align-items: flex-start; } } @media (prefers-reduced-motion: reduce) { .ddt-page *, .ddt-page *::before, .ddt-page *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } @@ -480,11 +416,6 @@ function Heading({ totals, adding, busy, onAdd, addButtonRef }) { }), h("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) ) : null, - totals.pendingApproval > 0 ? h( - "div", - { className: "ddt-badge", "data-tone": "attention" }, - `${totals.pendingApproval} \u4E2A\u5F85\u6279\u51C6` - ) : null, h("div", { className: "ddt-badge", title: "\u5E94\u7528\u5BC6\u94A5\u53EA\u5199\u5165 Harness Host \u51ED\u636E\u670D\u52A1\uFF0C\u4E0D\u4F1A\u53D1\u9001\u5230\u6D4F\u89C8\u5668" @@ -670,70 +601,6 @@ function checkedTime(value) { return "\u521A\u521A"; } } -function SenderAccess({ account, busy, onApprove, onRevoke }) { - const pending = account.senders.pending; - const approved = account.senders.approved; - return h( - "section", - { className: "ddt-access", "aria-label": `${account.bot.name}\u4F7F\u7528\u8005\u6743\u9650` }, - h( - "div", - { className: "ddt-accessHeader" }, - h( - "div", - null, - h("h4", null, "\u5141\u8BB8\u4F7F\u7528\u673A\u5668\u4EBA\u7684\u9489\u9489\u8D26\u53F7"), - h("p", null, "\u65B0\u4F7F\u7528\u8005\u9996\u6B21\u53D1\u6D88\u606F\u540E\u4F1A\u51FA\u73B0\u5728\u8FD9\u91CC\uFF0C\u987B\u5728\u672C\u673A\u660E\u786E\u6279\u51C6\u3002") - ), - h("span", { - className: "ddt-accessCount", - "data-pending": pending.length > 0 ? "true" : void 0 - }, pending.length > 0 ? `${pending.length} \u4E2A\u5F85\u6279\u51C6` : `${approved.length} \u4E2A\u5DF2\u6279\u51C6`) - ), - pending.length > 0 ? h("ul", { className: "ddt-senderList" }, pending.map((sender) => h( - "li", - { - className: "ddt-sender", - key: sender.requestId - }, - h( - "div", - { className: "ddt-senderIdentity" }, - h("strong", null, sender.displayName), - h("span", null, `${sender.senderIdMasked} \xB7 ${sender.conversationType === "group" ? "\u7FA4\u804A" : "\u5355\u804A"}`) - ), - h(Button, { - kind: "primary", - onClick: () => onApprove(sender), - disabled: Boolean(busy) - }, busy === `approve:${sender.requestId}` ? "\u6279\u51C6\u4E2D\u2026" : "\u6279\u51C6\u4F7F\u7528") - ))) : null, - approved.length > 0 ? h( - React.Fragment, - null, - h("div", { className: "ddt-approvedLabel" }, "\u5DF2\u6279\u51C6"), - h("ul", { className: "ddt-senderList" }, approved.map((sender) => h( - "li", - { - className: "ddt-sender", - key: sender.senderKey - }, - h( - "div", - { className: "ddt-senderIdentity" }, - h("strong", null, sender.displayName), - h("span", null, sender.senderIdMasked) - ), - h(Button, { - kind: "quiet", - onClick: () => onRevoke(sender), - disabled: Boolean(busy) - }, busy === `revoke:${sender.senderKey}` ? "\u64A4\u9500\u4E2D\u2026" : "\u64A4\u9500") - ))) - ) : null, - pending.length === 0 && approved.length === 0 ? h("p", { className: "ddt-noSenders" }, "\u5C1A\u65E0\u4F7F\u7528\u8BF7\u6C42\u3002\u673A\u5668\u4EBA\u6536\u5230\u7B2C\u4E00\u6761\u6D88\u606F\u540E\uFF0C\u53EF\u5728\u6B64\u6279\u51C6\u53D1\u9001\u8005\u3002") : null - ); -} function RemoveConfirmation({ account, busy, onConfirm, onCancel }) { const cancelRef = React.useRef(null); React.useEffect(() => cancelRef.current?.focus(), []); @@ -748,7 +615,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) { } }, h("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), - h("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\u3001\u4F1A\u8BDD\u6620\u5C04\u53CA\u4F7F\u7528\u8005\u6279\u51C6\u8BB0\u5F55\u3002\u9489\u9489\u5F00\u653E\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), + h("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"), h( "div", { className: "ddt-actions" }, @@ -768,9 +635,7 @@ function AccountCard({ onReconnect, onRequestRemove, onConfirmRemove, - onCancelRemove, - onApprove, - onRevoke + onCancelRemove }) { const state = busy === "reconnect" ? "connecting" : account.state; const tone = account.connected ? "success" : state === "error" ? "error" : "warning"; @@ -824,7 +689,6 @@ function AccountCard({ h("dd", null, checkedTime(account.health.lastCheckedAt)) ) ), - h(SenderAccess, { account, busy, onApprove, onRevoke }), h( "div", { className: "ddt-accountFooter" }, @@ -873,14 +737,12 @@ function AccountList(props) { onReconnect: () => props.onReconnect(account), onRequestRemove: () => props.onRequestRemove(account), onConfirmRemove: () => props.onConfirmRemove(account), - onCancelRemove: props.onCancelRemove, - onApprove: (sender) => props.onApprove(account, sender), - onRevoke: (sender) => props.onRevoke(account, sender) + onCancelRemove: props.onCancelRemove }) ))) ); } -var EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0, pendingApproval: 0 }); +var EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 }); function DingtalkSettingsTab({ rpcCall }) { const [model, setModel] = React.useState({ phase: "loading", @@ -896,19 +758,67 @@ function DingtalkSettingsTab({ rpcCall }) { const [notice, setNotice] = React.useState(""); const [now, setNow] = React.useState(() => Date.now()); const addButtonRef = React.useRef(null); + const mountedRef = React.useRef(true); + const statusRequestRef = React.useRef(0); + const noticeFrameRef = React.useRef(null); + const focusFrameRef = React.useRef(null); + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + statusRequestRef.current += 1; + if (noticeFrameRef.current !== null) { + window.cancelAnimationFrame(noticeFrameRef.current); + noticeFrameRef.current = null; + } + if (focusFrameRef.current !== null) { + window.cancelAnimationFrame(focusFrameRef.current); + focusFrameRef.current = null; + } + }; + }, []); React.useEffect(() => installDingtalkStyles(), []); const announce = React.useCallback((message) => { + if (!mountedRef.current) return; + if (noticeFrameRef.current !== null) { + window.cancelAnimationFrame(noticeFrameRef.current); + noticeFrameRef.current = null; + } setNotice(""); - if (message) window.requestAnimationFrame(() => setNotice(message)); + if (message) { + noticeFrameRef.current = window.requestAnimationFrame(() => { + noticeFrameRef.current = null; + if (mountedRef.current) setNotice(message); + }); + } + }, []); + const focusAddButton = React.useCallback(() => { + if (!mountedRef.current) return; + if (focusFrameRef.current !== null) window.cancelAnimationFrame(focusFrameRef.current); + focusFrameRef.current = window.requestAnimationFrame(() => { + focusFrameRef.current = null; + if (mountedRef.current) addButtonRef.current?.focus(); + }); }, []); const invoke = React.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 = React.useCallback(async ({ signal, silent = false } = {}) => { - if (!silent) setModel((current) => ({ ...current, phase: "loading", error: null })); + const loadStatus = React.useCallback(async ({ + signal, + silent = false, + restoreProvisioning = false + } = {}) => { + if (!mountedRef.current || signal?.aborted) return void 0; + const requestId = statusRequestRef.current + 1; + statusRequestRef.current = requestId; + const canCommit = () => mountedRef.current && !signal?.aborted && statusRequestRef.current === requestId; + if (!silent && canCommit()) { + setModel((current) => ({ ...current, phase: "loading", error: null })); + } try { const snapshot = normalizeSnapshot(await invoke(DINGTALK_ENDPOINTS.status, {}, signal)); + if (!canCommit()) return void 0; setModel({ phase: "ready", bots: snapshot.bots, @@ -916,7 +826,7 @@ function DingtalkSettingsTab({ rpcCall }) { revision: snapshot.revision, error: null }); - if (snapshot.provisioning) { + if (restoreProvisioning && snapshot.provisioning) { setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId ? { ...current, ...snapshot.provisioning, @@ -925,7 +835,7 @@ function DingtalkSettingsTab({ rpcCall }) { } return snapshot; } catch (error) { - if (error?.name === "AbortError") return void 0; + if (error?.name === "AbortError" || !canCommit()) return void 0; setModel((current) => ({ ...current, phase: silent && current.phase === "ready" ? "ready" : "error", @@ -936,7 +846,7 @@ function DingtalkSettingsTab({ rpcCall }) { }, [invoke]); React.useEffect(() => { const controller = new AbortController(); - void loadStatus({ signal: controller.signal }); + void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); React.useEffect(() => { @@ -944,9 +854,13 @@ function DingtalkSettingsTab({ rpcCall }) { const controller = new AbortController(); let running = false; const timer = window.setInterval(async () => { - if (running) return; + if (running || controller.signal.aborted || !mountedRef.current) return; running = true; - await loadStatus({ signal: controller.signal, silent: true }); + await loadStatus({ + signal: controller.signal, + silent: true, + restoreProvisioning: false + }); running = false; }, 15e3); return () => { @@ -956,22 +870,27 @@ function DingtalkSettingsTab({ rpcCall }) { }, [loadStatus, model.phase]); React.useEffect(() => { if (!provision || !ACTIVE_PROVISION_STATES.has(provision.status)) return void 0; - const timer = window.setInterval(() => setNow(Date.now()), 1e3); + const timer = window.setInterval(() => { + if (mountedRef.current) setNow(Date.now()); + }, 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); const startProvisioning = React.useCallback(async ({ replace = false } = {}) => { + if (!mountedRef.current) return; setBusy(true); try { if (replace && provision?.attemptId) { await invoke(DINGTALK_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + if (!mountedRef.current) return; } setProvision({ status: "starting" }); const started = normalizeProvisioning(await invoke( DINGTALK_ENDPOINTS.beginProvisioning, { locale: "zh-CN" } )); + if (!mountedRef.current) return; if (!started.qrCodeDataUrl) { throw new Error("\u9489\u9489\u626B\u7801\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u5B89\u5168\u7684\u4E8C\u7EF4\u7801"); } @@ -982,70 +901,95 @@ function DingtalkSettingsTab({ rpcCall }) { }); announce("\u9489\u9489\u4E8C\u7EF4\u7801\u5DF2\u751F\u6210\uFF0C\u8BF7\u4F7F\u7528\u9489\u9489 App \u626B\u63CF\u3002"); } catch (error) { + if (!mountedRef.current) return; setProvision({ attemptId: provision?.attemptId, status: "failed", error: presentError(error) }); } finally { - setBusy(false); + if (mountedRef.current) setBusy(false); } }, [announce, invoke, provision?.attemptId]); const cancelProvisioning = React.useCallback(async () => { + if (!mountedRef.current) return; setBusy(true); try { if (provision?.attemptId && !["failed", "expired", "cancelled"].includes(provision.status)) { await invoke(DINGTALK_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + if (!mountedRef.current) return; } setProvision(null); announce("\u5DF2\u53D6\u6D88\u9489\u9489\u673A\u5668\u4EBA\u63A5\u5165\u3002"); - window.requestAnimationFrame(() => addButtonRef.current?.focus()); + focusAddButton(); } catch (error) { + if (!mountedRef.current) return; setProvision((current) => ({ ...current, status: "failed", error: presentError(error) })); } finally { - setBusy(false); + if (mountedRef.current) setBusy(false); } - }, [announce, invoke, provision?.attemptId, provision?.status]); + }, [announce, focusAddButton, invoke, provision?.attemptId, provision?.status]); React.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !ACTIVE_PROVISION_STATES.has(provision.status)) return void 0; const controller = new AbortController(); - let timer; + let disposed = false; + let timer = null; + const canCommit = () => !disposed && !controller.signal.aborted && mountedRef.current; + const schedule = (delay) => { + if (!canCommit()) return; + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + timer = null; + if (canCommit()) void poll(); + }, delay); + }; const poll = async () => { try { - const result = normalizeProvisioning(await invoke( + const response = await invoke( DINGTALK_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal - )); + ); + if (!canCommit()) return; + const result = normalizeProvisioning(response); if (result.status === "connected") { - const snapshot = await loadStatus({ signal: controller.signal, silent: true }); + const snapshot = await loadStatus({ + signal: controller.signal, + silent: true, + restoreProvisioning: false + }); + if (!canCommit()) return; const account = result.botId ? snapshot?.bots.find((bot) => bot.botId === result.botId) : snapshot?.bots.find((bot) => bot.connected); if (!account?.connected) { setProvision((current) => current?.attemptId === attemptId ? { ...current, ...result, status: "connecting" } : current); - timer = window.setTimeout(poll, result.pollIntervalMs); + schedule(result.pollIntervalMs); return; } setProvision(null); announce(result.alreadyConnected ? "\u8FD9\u4E2A\u9489\u9489\u673A\u5668\u4EBA\u5DF2\u7ECF\u63A5\u5165\u5E76\u4FDD\u6301\u5728\u7EBF\u3002" : "\u9489\u9489\u673A\u5668\u4EBA\u5DF2\u63A5\u5165\uFF0C\u53EF\u4EE5\u5F00\u59CB\u53D1\u9001\u6D88\u606F\u3002"); return; } + if (!canCommit()) return; setProvision((current) => current?.attemptId === attemptId ? { ...current, ...result, durationMs: current.durationMs } : current); if (ACTIVE_PROVISION_STATES.has(result.status)) { - timer = window.setTimeout(poll, result.pollIntervalMs); + schedule(result.pollIntervalMs); } } catch (error) { - if (error?.name === "AbortError") return; + if (error?.name === "AbortError" || !canCommit()) return; setProvision((current) => current?.attemptId === attemptId ? { ...current, status: "failed", error: presentError(error) } : current); } }; - timer = window.setTimeout(poll, provision.pollIntervalMs ?? 3e3); + schedule(provision.pollIntervalMs ?? 3e3); return () => { + disposed = true; controller.abort(); - window.clearTimeout(timer); + if (timer !== null) window.clearTimeout(timer); + timer = null; }; }, [announce, invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); const setBotBusy = React.useCallback((botId, operation) => { + if (!mountedRef.current) return; setBusyByBot((current) => { const next = { ...current }; if (operation) next[botId] = operation; @@ -1054,17 +998,21 @@ function DingtalkSettingsTab({ rpcCall }) { }); }, []); const runBotAction = React.useCallback(async ({ account, operation, endpoint, payload, success }) => { + if (!mountedRef.current) return void 0; setBotBusy(account.botId, operation); try { await invoke(endpoint, payload); - const snapshot = await loadStatus({ silent: true }); + if (!mountedRef.current) return void 0; + const snapshot = await loadStatus({ silent: true, restoreProvisioning: false }); + if (!mountedRef.current) return void 0; announce(typeof success === "function" ? success(snapshot) : success); return snapshot; } catch (error) { + if (!mountedRef.current) return void 0; announce(`\u64CD\u4F5C\u5931\u8D25\uFF1A${presentError(error).message}`); return void 0; } finally { - setBotBusy(account.botId, null); + if (mountedRef.current) setBotBusy(account.botId, null); } }, [announce, invoke, loadStatus, setBotBusy]); const reconnect = React.useCallback((account) => runBotAction({ @@ -1082,22 +1030,8 @@ function DingtalkSettingsTab({ rpcCall }) { payload: { botId: account.botId, confirm: true }, success: "\u9489\u9489\u673A\u5668\u4EBA\u53CA\u672C\u673A\u51ED\u636E\u5DF2\u79FB\u9664\u3002" }); - if (snapshot) setRemoveTarget(null); + if (snapshot && mountedRef.current) setRemoveTarget(null); }, [runBotAction]); - const approve = React.useCallback((account, sender) => runBotAction({ - account, - operation: `approve:${sender.requestId}`, - endpoint: DINGTALK_ENDPOINTS.approveSender, - payload: { botId: account.botId, requestId: sender.requestId, confirm: true }, - success: `\u5DF2\u6279\u51C6 ${sender.displayName} \u4F7F\u7528\u8FD9\u4E2A\u673A\u5668\u4EBA\u3002` - }), [runBotAction]); - const revoke = React.useCallback((account, sender) => runBotAction({ - account, - operation: `revoke:${sender.senderKey}`, - endpoint: DINGTALK_ENDPOINTS.revokeSender, - payload: { botId: account.botId, senderKey: sender.senderKey, confirm: true }, - success: `\u5DF2\u64A4\u9500 ${sender.displayName} \u7684\u4F7F\u7528\u6743\u9650\u3002` - }), [runBotAction]); let provisionView = null; if (provision?.status === "starting") { provisionView = h( @@ -1162,16 +1096,14 @@ function DingtalkSettingsTab({ rpcCall }) { onReconnect: (account) => void reconnect(account), onRequestRemove: (account) => setRemoveTarget(account.botId), onConfirmRemove: (account) => void remove(account), - onCancelRemove: () => setRemoveTarget(null), - onApprove: (account, sender) => void approve(account, sender), - onRevoke: (account, sender) => void revoke(account, sender) + onCancelRemove: () => setRemoveTarget(null) }) : null ) ); } // plugin-src/client/channels/feishu/index.js -var React2 = __toESM(require("react"), 1); +var React3 = __toESM(require("react"), 1); // plugin-src/client/channels/feishu/api.js var FEISHU_RPC_CHANNEL = "/feishu"; @@ -1387,6 +1319,87 @@ function formatRemaining2(milliseconds) { return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; } +// plugin-src/client/lifecycle.js +var React2 = __toESM(require("react"), 1); +function createPollScheduler({ setTimeoutFn, clearTimeoutFn }) { + let disposed = false; + let timer; + return { + get disposed() { + return disposed; + }, + schedule(callback, delayMs) { + if (disposed) return false; + if (timer !== void 0) clearTimeoutFn(timer); + timer = setTimeoutFn(() => { + timer = void 0; + if (!disposed) void callback(); + }, delayMs); + return true; + }, + dispose() { + if (disposed) return; + disposed = true; + if (timer !== void 0) clearTimeoutFn(timer); + timer = void 0; + } + }; +} +function createAnimationFrameScheduler({ requestFrame, cancelFrame }) { + let disposed = false; + const frames = /* @__PURE__ */ new Set(); + const keyedFrames = /* @__PURE__ */ new Map(); + return { + schedule(callback, key) { + if (disposed) return false; + const previous = key === void 0 ? void 0 : keyedFrames.get(key); + if (previous !== void 0) { + keyedFrames.delete(key); + frames.delete(previous); + cancelFrame(previous); + } + let frame; + let completed = false; + frame = requestFrame(() => { + completed = true; + if (frame !== void 0) frames.delete(frame); + if (key !== void 0 && keyedFrames.get(key) === frame) keyedFrames.delete(key); + if (!disposed) callback(); + }); + if (!completed) { + frames.add(frame); + if (key !== void 0) keyedFrames.set(key, frame); + } + return true; + }, + dispose() { + if (disposed) return; + disposed = true; + for (const frame of frames) cancelFrame(frame); + frames.clear(); + keyedFrames.clear(); + } + }; +} +function useAnimationFrameScheduler() { + const schedulerRef = React2.useRef(null); + React2.useEffect(() => { + const scheduler = createAnimationFrameScheduler({ + requestFrame: (callback) => window.requestAnimationFrame(callback), + cancelFrame: (frame) => window.cancelAnimationFrame(frame) + }); + schedulerRef.current = scheduler; + return () => { + scheduler.dispose(); + if (schedulerRef.current === scheduler) schedulerRef.current = null; + }; + }, []); + return React2.useCallback( + (callback, key) => schedulerRef.current?.schedule(callback, key) ?? false, + [] + ); +} + // plugin-src/client/channels/feishu/styles.js var FEISHU_STYLE_ID = "beihuixinghe-dsh-feishu-settings"; var CSS2 = String.raw` @@ -1914,7 +1927,7 @@ function installFeishuStyles() { } // plugin-src/client/channels/feishu/index.js -var h2 = React2.createElement; +var h2 = React3.createElement; function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) { return h2("svg", { width: size, @@ -2033,7 +2046,7 @@ function QrIcon({ size = 58 }) { fill: "currentColor" })); } -var Button3 = React2.forwardRef(function Button4({ children, kind = "secondary", size, icon, className = "", ...props }, ref) { +var Button3 = React3.forwardRef(function Button4({ children, kind = "secondary", size, icon, className = "", ...props }, ref) { return h2("button", { ...props, ref, @@ -2147,13 +2160,13 @@ 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] = React2.useState(false); + const [imageFailed, setImageFailed] = React3.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)); - React2.useEffect(() => setImageFailed(false), [qrSource]); + React3.useEffect(() => setImageFailed(false), [qrSource]); return h2( "div", { className: "bxf-card bxf-provisionCard" }, @@ -2318,11 +2331,11 @@ function formatCheckedTime(timestamp3) { } } function RemoveConfirmation2({ bot, busy, onConfirm, onCancel }) { - const cancelRef = React2.useRef(null); + const cancelRef = React3.useRef(null); const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-"); const titleId = `bxf-remove-title-${idPart}`; const descriptionId = `bxf-remove-description-${idPart}`; - React2.useEffect(() => cancelRef.current?.focus(), []); + React3.useEffect(() => cancelRef.current?.focus(), []); return h2( "div", { @@ -2528,8 +2541,30 @@ function PageError({ error, onRetry, busy }) { ); } var EMPTY_TOTALS2 = Object.freeze({ configured: 0, connected: 0 }); +function mergeFeishuSnapshotState(current, snapshot, { restoreProvisioning = false, now = Date.now() } = {}) { + if (snapshot.revision > 0 && current.revision > snapshot.revision) return current; + let provisioning = current.provisioning; + if (!provisioning && restoreProvisioning && snapshot.provisioning) { + provisioning = { + phase: snapshot.state === "connecting" ? "connecting" : "qr", + ...snapshot.provisioning, + durationMs: Math.max(1, snapshot.provisioning.expiresAt - now), + expired: snapshot.provisioning.expiresAt <= now + }; + } + return { + ...current, + phase: "ready", + revision: snapshot.revision, + bots: snapshot.bots, + totals: snapshot.totals, + provisioning, + pageError: null, + statusError: null + }; +} function FeishuSettingsTab({ rpcCall }) { - const [model, setModel] = React2.useState({ + const [model, setModel] = React3.useState({ phase: "loading", revision: 0, bots: [], @@ -2538,77 +2573,68 @@ function FeishuSettingsTab({ rpcCall }) { pageError: null, statusError: null }); - const [pageBusy, setPageBusy] = React2.useState(false); - const [provisionBusy, setProvisionBusy] = React2.useState(false); - const [busyByBot, setBusyByBot] = React2.useState({}); - const [errorsByBot, setErrorsByBot] = React2.useState({}); - const [removeTargetId, setRemoveTargetId] = React2.useState(null); - const [announcement, setAnnouncement] = React2.useState(""); - const [now, setNow] = React2.useState(() => Date.now()); - const [focusBotId, setFocusBotId] = React2.useState(null); - const cardRefs = React2.useRef(/* @__PURE__ */ new Map()); - const removeButtonRefs = React2.useRef(/* @__PURE__ */ new Map()); - const addButtonRef = React2.useRef(null); - const announce = React2.useCallback((message) => { + const [pageBusy, setPageBusy] = React3.useState(false); + const [provisionBusy, setProvisionBusy] = React3.useState(false); + const [busyByBot, setBusyByBot] = React3.useState({}); + const [errorsByBot, setErrorsByBot] = React3.useState({}); + const [removeTargetId, setRemoveTargetId] = React3.useState(null); + const [announcement, setAnnouncement] = React3.useState(""); + const [now, setNow] = React3.useState(() => Date.now()); + const [focusBotId, setFocusBotId] = React3.useState(null); + const cardRefs = React3.useRef(/* @__PURE__ */ new Map()); + const removeButtonRefs = React3.useRef(/* @__PURE__ */ new Map()); + const addButtonRef = React3.useRef(null); + const scheduleAnimationFrame = useAnimationFrameScheduler(); + const announce = React3.useCallback((message) => { setAnnouncement(""); - if (!message) return; - window.requestAnimationFrame(() => setAnnouncement(message)); - }, []); - const invoke = React2.useCallback(async (endpoint, payload = {}, signal) => { + scheduleAnimationFrame(() => { + if (message) setAnnouncement(message); + }, "announcement"); + }, [scheduleAnimationFrame]); + const invoke = React3.useCallback(async (endpoint, payload = {}, signal) => { return unwrapRpcResult2(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const mergeSnapshot = React2.useCallback((snapshot, { restoreProvisioning = true } = {}) => { - setModel((current) => { - if (snapshot.revision > 0 && current.revision > snapshot.revision) return current; - let provisioning = current.provisioning; - if (!provisioning && restoreProvisioning && snapshot.provisioning) { - provisioning = { - phase: snapshot.state === "connecting" ? "connecting" : "qr", - ...snapshot.provisioning, - durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()), - expired: snapshot.provisioning.expiresAt <= Date.now() - }; - } - return { - ...current, - phase: "ready", - revision: snapshot.revision, - bots: snapshot.bots, - totals: snapshot.totals, - provisioning, - pageError: null, - statusError: null - }; - }); + const mergeSnapshot = React3.useCallback((snapshot, { restoreProvisioning = false } = {}) => { + const now2 = Date.now(); + setModel((current) => mergeFeishuSnapshotState( + current, + snapshot, + { restoreProvisioning, now: now2 } + )); }, []); - const loadStatus = React2.useCallback(async ({ signal, silent = false, restoreProvisioning = true } = {}) => { + const loadStatus = React3.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { if (!silent) setPageBusy(true); try { const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal)); + if (signal?.aborted) return void 0; mergeSnapshot(snapshot, { restoreProvisioning }); return snapshot; } catch (error) { - if (error?.name === "AbortError") return void 0; + if (signal?.aborted || error?.name === "AbortError") return void 0; const presented = presentError2(error); setModel((current) => current.phase === "loading" || !silent ? { ...current, phase: "error", pageError: presented } : { ...current, statusError: presented }); return void 0; } finally { - if (!silent) setPageBusy(false); + if (!silent && !signal?.aborted) setPageBusy(false); } }, [invoke, mergeSnapshot]); - React2.useEffect(() => { + React3.useEffect(() => { const controller = new AbortController(); - void loadStatus({ signal: controller.signal }); + 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 inFlight = false; const timer = window.setInterval(async () => { if (inFlight) return; inFlight = true; - await loadStatus({ signal: controller.signal, silent: true }); + await loadStatus({ + signal: controller.signal, + silent: true, + restoreProvisioning: false + }); inFlight = false; }, 15e3); return () => { @@ -2616,7 +2642,7 @@ function FeishuSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React2.useEffect(() => { + React3.useEffect(() => { if (!focusBotId) return; const node = cardRefs.current.get(focusBotId); if (!node) return; @@ -2624,7 +2650,7 @@ function FeishuSettingsTab({ rpcCall }) { node.focus({ preventScroll: true }); setFocusBotId(null); }, [focusBotId, model.bots]); - const startProvisioning = React2.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React3.useCallback(async ({ replace = false } = {}) => { setProvisionBusy(true); announce(""); const previousAttemptId = model.provisioning?.attemptId; @@ -2662,7 +2688,7 @@ function FeishuSettingsTab({ rpcCall }) { setProvisionBusy(false); } }, [announce, invoke, model.provisioning?.attemptId]); - const cancelProvisioning = React2.useCallback(async () => { + const cancelProvisioning = React3.useCallback(async () => { const attemptId = model.provisioning?.attemptId; setProvisionBusy(true); try { @@ -2670,7 +2696,7 @@ function FeishuSettingsTab({ rpcCall }) { setModel((current) => ({ ...current, provisioning: null })); announce("\u5DF2\u53D6\u6D88\u6DFB\u52A0\u673A\u5668\u4EBA\u3002"); await loadStatus({ silent: true, restoreProvisioning: false }); - window.requestAnimationFrame(() => addButtonRef.current?.focus()); + scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus"); } catch (error) { setModel((current) => ({ ...current, @@ -2679,22 +2705,25 @@ function FeishuSettingsTab({ rpcCall }) { } finally { setProvisionBusy(false); } - }, [announce, invoke, loadStatus, model.provisioning?.attemptId]); - React2.useEffect(() => { - const provision2 = model.provisioning; - if (!provision2 || provision2.phase !== "qr" || provision2.expired) return void 0; + }, [announce, invoke, loadStatus, model.provisioning?.attemptId, scheduleAnimationFrame]); + const countdownAttemptId = model.provisioning?.attemptId; + const countdownPhase = model.provisioning?.phase; + const countdownExpiresAt = model.provisioning?.expiresAt; + const countdownExpired = model.provisioning?.expired; + React3.useEffect(() => { + if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return void 0; const tick = () => { const timestamp3 = Date.now(); setNow(timestamp3); - if (timestamp3 >= provision2.expiresAt) { - setModel((current) => current.provisioning?.attemptId === provision2.attemptId ? { ...current, provisioning: { ...current.provisioning, expired: true } } : current); + if (timestamp3 >= countdownExpiresAt) { + setModel((current) => current.provisioning?.attemptId === countdownAttemptId ? { ...current, provisioning: { ...current.provisioning, expired: true } } : current); } }; tick(); const timer = window.setInterval(tick, 1e3); return () => window.clearInterval(timer); - }, [model.provisioning]); - React2.useEffect(() => { + }, [countdownAttemptId, countdownPhase, countdownExpiresAt, countdownExpired]); + React3.useEffect(() => { const provision2 = model.provisioning; if (!provision2 || !["qr", "connecting"].includes(provision2.phase) || !provision2.attemptId || provision2.expired) return void 0; const controller = new AbortController(); @@ -2758,7 +2787,7 @@ function FeishuSettingsTab({ rpcCall }) { window.clearTimeout(timer); }; }, [announce, invoke, loadStatus, model.provisioning]); - const setBotBusy = React2.useCallback((botId, value) => { + const setBotBusy = React3.useCallback((botId, value) => { setBusyByBot((current) => { const next = { ...current }; if (value) next[botId] = value; @@ -2766,7 +2795,7 @@ function FeishuSettingsTab({ rpcCall }) { return next; }); }, []); - const setBotError = React2.useCallback((botId, error) => { + const setBotError = React3.useCallback((botId, error) => { setErrorsByBot((current) => { const next = { ...current }; if (error) next[botId] = presentError2(error); @@ -2774,7 +2803,7 @@ function FeishuSettingsTab({ rpcCall }) { return next; }); }, []); - const reconnectOneBot = React2.useCallback(async (connection) => { + const reconnectOneBot = React3.useCallback(async (connection) => { const { botId, bot } = connection; setBotBusy(botId, "reconnect"); setBotError(botId, null); @@ -2797,15 +2826,15 @@ function FeishuSettingsTab({ rpcCall }) { setBotBusy(botId, null); } }, [announce, invoke, mergeSnapshot, setBotBusy, setBotError]); - const requestRemove = React2.useCallback((connection) => { + const requestRemove = React3.useCallback((connection) => { setRemoveTargetId(connection.botId); }, []); - const cancelRemove = React2.useCallback(() => { + const cancelRemove = React3.useCallback(() => { const botId = removeTargetId; setRemoveTargetId(null); - window.requestAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus()); - }, [removeTargetId]); - const confirmRemove = React2.useCallback(async (connection) => { + scheduleAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus(), "focus"); + }, [removeTargetId, scheduleAnimationFrame]); + const confirmRemove = React3.useCallback(async (connection) => { const { botId, bot } = connection; setBotBusy(botId, "delete"); setBotError(botId, null); @@ -2825,14 +2854,14 @@ function FeishuSettingsTab({ rpcCall }) { }); announce(`${bot.name}\u5DF2\u4ECE\u6B64 DeepSeek Harness \u79FB\u9664\uFF1B\u98DE\u4E66\u5F00\u653E\u5E73\u53F0\u4E2D\u7684\u5E94\u7528\u672A\u88AB\u5220\u9664\u3002`); await loadStatus({ silent: true }); - window.requestAnimationFrame(() => addButtonRef.current?.focus()); + scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus"); } catch (error) { setBotError(botId, error); announce(`${bot.name}\u79FB\u9664\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002`); } finally { setBotBusy(botId, null); } - }, [announce, invoke, loadStatus, setBotBusy, setBotError]); + }, [announce, invoke, loadStatus, scheduleAnimationFrame, setBotBusy, setBotError]); const provision = model.provisioning; let provisionContent = null; if (provision?.phase === "creating") { @@ -2859,11 +2888,11 @@ function FeishuSettingsTab({ rpcCall }) { busy: provisionBusy }); } - const setCardRef = React2.useCallback((botId, node) => { + const setCardRef = React3.useCallback((botId, node) => { if (node) cardRefs.current.set(botId, node); else cardRefs.current.delete(botId); }, []); - const setRemoveButtonRef = React2.useCallback((botId, node) => { + const setRemoveButtonRef = React3.useCallback((botId, node) => { if (node) removeButtonRefs.current.set(botId, node); else removeButtonRefs.current.delete(botId); }, []); @@ -2895,7 +2924,7 @@ function FeishuSettingsTab({ rpcCall }) { onRetry: () => void loadStatus(), busy: pageBusy }) : h2( - React2.Fragment, + React3.Fragment, null, provisionContent, model.bots.length === 0 && !provision ? h2(EmptyView2, { onStart: () => void startProvisioning(), busy: provisionBusy }) : null, @@ -2916,7 +2945,7 @@ function FeishuSettingsTab({ rpcCall }) { } // plugin-src/client/channels/weixin/index.js -var React3 = __toESM(require("react"), 1); +var React4 = __toESM(require("react"), 1); // plugin-src/client/channels/weixin/api.js var WEIXIN_RPC_CHANNEL = "/weixin"; @@ -3179,7 +3208,7 @@ function installWeixinStyles() { } // plugin-src/client/channels/weixin/index.js -var h3 = React3.createElement; +var h3 = React4.createElement; function WeixinIcon({ size = 26 }) { return h3( "svg", @@ -3208,7 +3237,7 @@ function WeixinIcon({ size = 26 }) { h3("circle", { cx: "16.3", cy: "13.2", r: ".9", fill: "white" }) ); } -var Button5 = React3.forwardRef(function Button6({ children, kind = "secondary", className = "", ...props }, ref) { +var Button5 = React4.forwardRef(function Button6({ children, kind = "secondary", className = "", ...props }, ref) { return h3("button", { ...props, ref, @@ -3285,14 +3314,14 @@ function EmptyView3({ onStart, busy }) { ); } function QrPanel2({ provision, now, busy, onRefresh, onCancel }) { - const [imageFailed, setImageFailed] = React3.useState(false); + const [imageFailed, setImageFailed] = React4.useState(false); const source = safeQrSource3(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); - React3.useEffect(() => setImageFailed(false), [source]); + React4.useEffect(() => setImageFailed(false), [source]); return h3( "div", { className: "dxw-card" }, @@ -3359,9 +3388,9 @@ function QrPanel2({ provision, now, busy, onRefresh, onCancel }) { ); } function VerificationPanel({ provision, busy, onSubmit, onCancel }) { - const [code, setCode] = React3.useState(""); + const [code, setCode] = React4.useState(""); const valid = /^\d{4,8}$/.test(code); - React3.useEffect(() => setCode(""), [provision.attemptId]); + React4.useEffect(() => setCode(""), [provision.attemptId]); return h3( "div", { className: "dxw-card" }, @@ -3554,32 +3583,49 @@ function AccountList2(props) { ); } var EMPTY_TOTALS3 = Object.freeze({ configured: 0, connected: 0 }); +function mergeWeixinProvisioningSnapshot(current, incoming, { restoreProvisioning = false } = {}) { + if (!incoming || !current && !restoreProvisioning) return current; + if (current && current.attemptId !== incoming.attemptId) return current; + return { + ...current, + ...incoming, + durationMs: current?.durationMs ?? 5 * 6e4 + }; +} function WeixinSettingsTab({ rpcCall }) { - const [model, setModel] = React3.useState({ + const [model, setModel] = React4.useState({ phase: "loading", bots: [], totals: EMPTY_TOTALS3, revision: 0, error: null }); - const [provision, setProvision] = React3.useState(null); - const [busy, setBusy] = React3.useState(false); - const [busyByBot, setBusyByBot] = React3.useState({}); - const [removeTarget, setRemoveTarget] = React3.useState(null); - const [notice, setNotice] = React3.useState(""); - const [now, setNow] = React3.useState(() => Date.now()); - const addButtonRef = React3.useRef(null); - const announce = React3.useCallback((value) => { + const [provision, setProvision] = React4.useState(null); + const [busy, setBusy] = React4.useState(false); + const [busyByBot, setBusyByBot] = React4.useState({}); + const [removeTarget, setRemoveTarget] = React4.useState(null); + const [notice, setNotice] = React4.useState(""); + const [now, setNow] = React4.useState(() => Date.now()); + const addButtonRef = React4.useRef(null); + const scheduleAnimationFrame = useAnimationFrameScheduler(); + const announce = React4.useCallback((value) => { setNotice(""); - if (value) window.requestAnimationFrame(() => setNotice(value)); - }, []); - const invoke = React3.useCallback(async (endpoint, payload = {}, signal) => { + scheduleAnimationFrame(() => { + if (value) setNotice(value); + }, "announcement"); + }, [scheduleAnimationFrame]); + const invoke = React4.useCallback(async (endpoint, payload = {}, signal) => { return unwrapRpcResult3(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React3.useCallback(async ({ signal, silent = false } = {}) => { + const loadStatus = React4.useCallback(async ({ + signal, + silent = false, + restoreProvisioning = false + } = {}) => { if (!silent) setModel((current) => ({ ...current, phase: "loading", error: null })); try { const snapshot = normalizeSnapshot2(await invoke(WEIXIN_ENDPOINTS.status, {}, signal)); + if (signal?.aborted) return void 0; setModel({ phase: "ready", bots: snapshot.bots, @@ -3588,11 +3634,15 @@ function WeixinSettingsTab({ rpcCall }) { error: null }); if (snapshot.provisioning) { - setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId ? { ...current, ...snapshot.provisioning, durationMs: current?.durationMs ?? 5 * 6e4 } : current); + setProvision((current) => mergeWeixinProvisioningSnapshot( + current, + snapshot.provisioning, + { restoreProvisioning } + )); } return snapshot; } catch (error) { - if (error?.name === "AbortError") return void 0; + if (signal?.aborted || error?.name === "AbortError") return void 0; setModel((current) => ({ ...current, phase: silent && current.phase === "ready" ? "ready" : "error", @@ -3601,19 +3651,23 @@ function WeixinSettingsTab({ rpcCall }) { return void 0; } }, [invoke]); - React3.useEffect(() => { + React4.useEffect(() => { const controller = new AbortController(); - void loadStatus({ signal: controller.signal }); + void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); - React3.useEffect(() => { + React4.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); let running = false; const timer = window.setInterval(async () => { if (running) return; running = true; - await loadStatus({ signal: controller.signal, silent: true }); + await loadStatus({ + signal: controller.signal, + silent: true, + restoreProvisioning: false + }); running = false; }, 15e3); return () => { @@ -3621,12 +3675,12 @@ function WeixinSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React3.useEffect(() => { + React4.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 = React3.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React4.useCallback(async ({ replace = false } = {}) => { setBusy(true); try { if (replace && provision?.attemptId) { @@ -3647,7 +3701,7 @@ function WeixinSettingsTab({ rpcCall }) { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - const cancelProvisioning = React3.useCallback(async () => { + const cancelProvisioning = React4.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && !["failed", "expired", "cancelled"].includes(provision.status)) { @@ -3655,14 +3709,14 @@ function WeixinSettingsTab({ rpcCall }) { } setProvision(null); announce("\u5DF2\u53D6\u6D88\u5FAE\u4FE1\u7ED1\u5B9A\u3002"); - window.requestAnimationFrame(() => addButtonRef.current?.focus()); + scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus"); } catch (error) { setProvision((current) => ({ ...current, status: "failed", error: presentError3(error) })); } finally { setBusy(false); } - }, [announce, invoke, provision?.attemptId, provision?.status]); - const submitVerification = React3.useCallback(async (verifyCode) => { + }, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]); + const submitVerification = React4.useCallback(async (verifyCode) => { if (!provision?.attemptId) return; setBusy(true); try { @@ -3678,11 +3732,14 @@ function WeixinSettingsTab({ rpcCall }) { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - React3.useEffect(() => { + React4.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !["pending", "scanned", "connecting"].includes(provision.status)) return void 0; const controller = new AbortController(); - let timer; + const scheduler = createPollScheduler({ + setTimeoutFn: (callback, delayMs) => window.setTimeout(callback, delayMs), + clearTimeoutFn: (timer) => window.clearTimeout(timer) + }); const poll = async () => { try { const result = normalizeProvisioning3(await invoke( @@ -3690,12 +3747,18 @@ function WeixinSettingsTab({ rpcCall }) { { attemptId }, controller.signal )); + if (scheduler.disposed) return; if (result.status === "connected") { - const snapshot = await loadStatus({ signal: controller.signal, silent: true }); + const snapshot = await loadStatus({ + signal: controller.signal, + silent: true, + restoreProvisioning: false + }); + if (scheduler.disposed) return; const account = snapshot?.bots.find((bot) => bot.botId === result.botId); if (!account?.connected) { setProvision((current) => current?.attemptId === attemptId ? { ...current, ...result, status: "connecting" } : current); - timer = window.setTimeout(poll, result.pollIntervalMs); + scheduler.schedule(poll, result.pollIntervalMs); return; } setProvision(null); @@ -3704,20 +3767,20 @@ function WeixinSettingsTab({ rpcCall }) { } setProvision((current) => current?.attemptId === attemptId ? { ...current, ...result, durationMs: current.durationMs } : current); if (["pending", "scanned", "connecting"].includes(result.status)) { - timer = window.setTimeout(poll, result.pollIntervalMs); + scheduler.schedule(poll, result.pollIntervalMs); } } catch (error) { - if (error?.name === "AbortError") return; + if (scheduler.disposed || error?.name === "AbortError") return; setProvision((current) => current?.attemptId === attemptId ? { ...current, status: "failed", error: presentError3(error) } : current); } }; - timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1e3); + scheduler.schedule(poll, provision.pollIntervalMs ?? 1e3); return () => { + scheduler.dispose(); controller.abort(); - window.clearTimeout(timer); }; }, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]); - const setBotBusy = React3.useCallback((botId, value) => { + const setBotBusy = React4.useCallback((botId, value) => { setBusyByBot((current) => { const next = { ...current }; if (value) next[botId] = value; @@ -3725,7 +3788,7 @@ function WeixinSettingsTab({ rpcCall }) { return next; }); }, []); - const reconnect = React3.useCallback(async (account) => { + const reconnect = React4.useCallback(async (account) => { setBotBusy(account.botId, "reconnect"); try { const snapshot = normalizeSnapshot2(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId })); @@ -3738,7 +3801,7 @@ function WeixinSettingsTab({ rpcCall }) { setBotBusy(account.botId, null); } }, [announce, invoke, setBotBusy]); - const remove = React3.useCallback(async (account) => { + const remove = React4.useCallback(async (account) => { setBotBusy(account.botId, "delete"); try { const snapshot = normalizeSnapshot2(await invoke(WEIXIN_ENDPOINTS.deleteBot, { @@ -3809,7 +3872,7 @@ function WeixinSettingsTab({ rpcCall }) { h3(Button5, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") ) ) : h3( - React3.Fragment, + React4.Fragment, null, provisionView, model.bots.length === 0 && !provision ? h3(EmptyView3, { onStart: () => void startProvisioning(), busy }) : null, @@ -3904,7 +3967,7 @@ function installImStyles() { } // plugin-src/client/index.js -var h4 = React4.createElement; +var h4 = React5.createElement; var name = "im-settings"; var inject = ["slots", "connection"]; var CHANNELS = Object.freeze([ @@ -3959,7 +4022,7 @@ function ChannelLogo({ channel }) { return h4(DingtalkLogo); } function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, weixinRpcCall }) { - const [selected, setSelected] = React4.useState("weixin"); + const [selected, setSelected] = React5.useState("weixin"); const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0]; return h4( "section", diff --git a/package-lock.json b/package-lock.json index f4d8969..90eeb0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/e21177c3ba845f42d8f72f68b63cce67fe1a1d55.tar.gz", + "@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz", "@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz", "@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz" }, @@ -550,8 +550,8 @@ }, "node_modules/@xmanrui/dsh-dingtalk": { "version": "0.1.0", - "resolved": "https://github.com/xmanrui/dsh-dingtalk/archive/e21177c3ba845f42d8f72f68b63cce67fe1a1d55.tar.gz", - "integrity": "sha512-zox9q7LIhD26re/8SFGGQxLFOH1J8viRXHKdmHftfLyoZBfRNLYkXijjhpdT6tjpAqIgSYo2Zp6NTV+2/Kcg7g==", + "resolved": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz", + "integrity": "sha512-jQMxICgQueYMRpDcK5R1pcsIxlB1DQNYzZqx2iE/LuhQvBpM7lPF8X/VmkC9Co6Gzgr919KcqJ6HvkO2pzF48w==", "license": "MIT", "dependencies": { "dingtalk-stream": "2.1.4", diff --git a/package.json b/package.json index 1d772e2..1c89afa 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "node": ">=22.19" }, "dependencies": { - "@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/e21177c3ba845f42d8f72f68b63cce67fe1a1d55.tar.gz", + "@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz", "@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz", "@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz" }, diff --git a/plugin-src/client/channels/feishu/index.js b/plugin-src/client/channels/feishu/index.js index 74f0634..f6b15b5 100644 --- a/plugin-src/client/channels/feishu/index.js +++ b/plugin-src/client/channels/feishu/index.js @@ -10,6 +10,7 @@ import { presentError, unwrapRpcResult, } from "./api.js"; +import { useAnimationFrameScheduler } from "../../lifecycle.js"; import { installFeishuStyles } from "./styles.js"; const h = React.createElement; @@ -486,6 +487,33 @@ function PageError({ error, onRetry, busy }) { const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 }); +export function mergeFeishuSnapshotState( + current, + snapshot, + { restoreProvisioning = false, now = Date.now() } = {}, +) { + if (snapshot.revision > 0 && current.revision > snapshot.revision) return current; + let provisioning = current.provisioning; + if (!provisioning && restoreProvisioning && snapshot.provisioning) { + provisioning = { + phase: snapshot.state === "connecting" ? "connecting" : "qr", + ...snapshot.provisioning, + durationMs: Math.max(1, snapshot.provisioning.expiresAt - now), + expired: snapshot.provisioning.expiresAt <= now, + }; + } + return { + ...current, + phase: "ready", + revision: snapshot.revision, + bots: snapshot.bots, + totals: snapshot.totals, + provisioning, + pageError: null, + statusError: null, + }; +} + export function FeishuSettingsTab({ rpcCall }) { const [model, setModel] = React.useState({ phase: "loading", @@ -507,63 +535,50 @@ export function FeishuSettingsTab({ rpcCall }) { const cardRefs = React.useRef(new Map()); const removeButtonRefs = React.useRef(new Map()); const addButtonRef = React.useRef(null); + const scheduleAnimationFrame = useAnimationFrameScheduler(); const announce = React.useCallback((message) => { setAnnouncement(""); - if (!message) return; - window.requestAnimationFrame(() => setAnnouncement(message)); - }, []); + scheduleAnimationFrame(() => { + if (message) setAnnouncement(message); + }, "announcement"); + }, [scheduleAnimationFrame]); const invoke = React.useCallback(async (endpoint, payload = {}, signal) => { return unwrapRpcResult(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const mergeSnapshot = React.useCallback((snapshot, { restoreProvisioning = true } = {}) => { - setModel((current) => { - if (snapshot.revision > 0 && current.revision > snapshot.revision) return current; - let provisioning = current.provisioning; - if (!provisioning && restoreProvisioning && snapshot.provisioning) { - provisioning = { - phase: snapshot.state === "connecting" ? "connecting" : "qr", - ...snapshot.provisioning, - durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()), - expired: snapshot.provisioning.expiresAt <= Date.now(), - }; - } - return { - ...current, - phase: "ready", - revision: snapshot.revision, - bots: snapshot.bots, - totals: snapshot.totals, - provisioning, - pageError: null, - statusError: null, - }; - }); + const mergeSnapshot = React.useCallback((snapshot, { restoreProvisioning = false } = {}) => { + const now = Date.now(); + setModel((current) => mergeFeishuSnapshotState( + current, + snapshot, + { restoreProvisioning, now }, + )); }, []); - const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = true } = {}) => { + const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { if (!silent) setPageBusy(true); try { const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal)); + if (signal?.aborted) return undefined; mergeSnapshot(snapshot, { restoreProvisioning }); return snapshot; } catch (error) { - if (error?.name === "AbortError") return undefined; + if (signal?.aborted || error?.name === "AbortError") return undefined; const presented = presentError(error); setModel((current) => current.phase === "loading" || !silent ? { ...current, phase: "error", pageError: presented } : { ...current, statusError: presented }); return undefined; } finally { - if (!silent) setPageBusy(false); + if (!silent && !signal?.aborted) setPageBusy(false); } }, [invoke, mergeSnapshot]); React.useEffect(() => { const controller = new AbortController(); - void loadStatus({ signal: controller.signal }); + void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); @@ -576,7 +591,11 @@ export function FeishuSettingsTab({ rpcCall }) { const timer = window.setInterval(async () => { if (inFlight) return; inFlight = true; - await loadStatus({ signal: controller.signal, silent: true }); + await loadStatus({ + signal: controller.signal, + silent: true, + restoreProvisioning: false, + }); inFlight = false; }, 15_000); return () => { @@ -641,7 +660,7 @@ export function FeishuSettingsTab({ rpcCall }) { setModel((current) => ({ ...current, provisioning: null })); announce("已取消添加机器人。"); await loadStatus({ silent: true, restoreProvisioning: false }); - window.requestAnimationFrame(() => addButtonRef.current?.focus()); + scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus"); } catch (error) { setModel((current) => ({ ...current, @@ -650,16 +669,19 @@ export function FeishuSettingsTab({ rpcCall }) { } finally { setProvisionBusy(false); } - }, [announce, invoke, loadStatus, model.provisioning?.attemptId]); + }, [announce, invoke, loadStatus, model.provisioning?.attemptId, scheduleAnimationFrame]); + const countdownAttemptId = model.provisioning?.attemptId; + const countdownPhase = model.provisioning?.phase; + const countdownExpiresAt = model.provisioning?.expiresAt; + const countdownExpired = model.provisioning?.expired; React.useEffect(() => { - const provision = model.provisioning; - if (!provision || provision.phase !== "qr" || provision.expired) return undefined; + if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return undefined; const tick = () => { const timestamp = Date.now(); setNow(timestamp); - if (timestamp >= provision.expiresAt) { - setModel((current) => current.provisioning?.attemptId === provision.attemptId + if (timestamp >= countdownExpiresAt) { + setModel((current) => current.provisioning?.attemptId === countdownAttemptId ? { ...current, provisioning: { ...current.provisioning, expired: true } } : current); } @@ -667,7 +689,7 @@ export function FeishuSettingsTab({ rpcCall }) { tick(); const timer = window.setInterval(tick, 1_000); return () => window.clearInterval(timer); - }, [model.provisioning]); + }, [countdownAttemptId, countdownPhase, countdownExpiresAt, countdownExpired]); React.useEffect(() => { const provision = model.provisioning; @@ -797,8 +819,8 @@ export function FeishuSettingsTab({ rpcCall }) { const cancelRemove = React.useCallback(() => { const botId = removeTargetId; setRemoveTargetId(null); - window.requestAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus()); - }, [removeTargetId]); + scheduleAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus(), "focus"); + }, [removeTargetId, scheduleAnimationFrame]); const confirmRemove = React.useCallback(async (connection) => { const { botId, bot } = connection; @@ -820,14 +842,14 @@ export function FeishuSettingsTab({ rpcCall }) { }); announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`); await loadStatus({ silent: true }); - window.requestAnimationFrame(() => addButtonRef.current?.focus()); + scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus"); } catch (error) { setBotError(botId, error); announce(`${bot.name}移除失败,请重试。`); } finally { setBotBusy(botId, null); } - }, [announce, invoke, loadStatus, setBotBusy, setBotError]); + }, [announce, invoke, loadStatus, scheduleAnimationFrame, setBotBusy, setBotError]); const provision = model.provisioning; let provisionContent = null; diff --git a/plugin-src/client/channels/weixin/index.js b/plugin-src/client/channels/weixin/index.js index f44e91b..f390378 100644 --- a/plugin-src/client/channels/weixin/index.js +++ b/plugin-src/client/channels/weixin/index.js @@ -11,6 +11,7 @@ import { safeVerificationUrl, unwrapRpcResult, } from './api.js'; +import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js'; import { installWeixinStyles } from './styles.js'; const h = React.createElement; @@ -267,6 +268,20 @@ function AccountList(props) { const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 }); +export function mergeWeixinProvisioningSnapshot( + current, + incoming, + { restoreProvisioning = false } = {}, +) { + if (!incoming || (!current && !restoreProvisioning)) return current; + if (current && current.attemptId !== incoming.attemptId) return current; + return { + ...current, + ...incoming, + durationMs: current?.durationMs ?? 5 * 60_000, + }; +} + export function WeixinSettingsTab({ rpcCall }) { const [model, setModel] = React.useState({ phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null, @@ -278,30 +293,40 @@ export function WeixinSettingsTab({ rpcCall }) { const [notice, setNotice] = React.useState(''); const [now, setNow] = React.useState(() => Date.now()); const addButtonRef = React.useRef(null); + const scheduleAnimationFrame = useAnimationFrameScheduler(); const announce = React.useCallback((value) => { setNotice(''); - if (value) window.requestAnimationFrame(() => setNotice(value)); - }, []); + scheduleAnimationFrame(() => { + if (value) setNotice(value); + }, 'announcement'); + }, [scheduleAnimationFrame]); const invoke = React.useCallback(async (endpoint, payload = {}, signal) => { return unwrapRpcResult(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React.useCallback(async ({ signal, silent = false } = {}) => { + const loadStatus = React.useCallback(async ({ + signal, + silent = false, + restoreProvisioning = false, + } = {}) => { if (!silent) setModel((current) => ({ ...current, phase: 'loading', error: null })); try { const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal)); + if (signal?.aborted) return undefined; setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision, error: null, }); if (snapshot.provisioning) { - setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId - ? { ...current, ...snapshot.provisioning, durationMs: current?.durationMs ?? 5 * 60_000 } - : current); + setProvision((current) => mergeWeixinProvisioningSnapshot( + current, + snapshot.provisioning, + { restoreProvisioning }, + )); } return snapshot; } catch (error) { - if (error?.name === 'AbortError') return undefined; + if (signal?.aborted || error?.name === 'AbortError') return undefined; setModel((current) => ({ ...current, phase: silent && current.phase === 'ready' ? 'ready' : 'error', @@ -313,7 +338,7 @@ export function WeixinSettingsTab({ rpcCall }) { React.useEffect(() => { const controller = new AbortController(); - void loadStatus({ signal: controller.signal }); + void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); @@ -324,7 +349,11 @@ export function WeixinSettingsTab({ rpcCall }) { const timer = window.setInterval(async () => { if (running) return; running = true; - await loadStatus({ signal: controller.signal, silent: true }); + await loadStatus({ + signal: controller.signal, + silent: true, + restoreProvisioning: false, + }); running = false; }, 15_000); return () => { @@ -369,13 +398,13 @@ export function WeixinSettingsTab({ rpcCall }) { } setProvision(null); announce('已取消微信绑定。'); - window.requestAnimationFrame(() => addButtonRef.current?.focus()); + scheduleAnimationFrame(() => addButtonRef.current?.focus(), 'focus'); } catch (error) { setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) })); } finally { setBusy(false); } - }, [announce, invoke, provision?.attemptId, provision?.status]); + }, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]); const submitVerification = React.useCallback(async (verifyCode) => { if (!provision?.attemptId) return; @@ -398,7 +427,10 @@ export function WeixinSettingsTab({ rpcCall }) { const attemptId = provision?.attemptId; if (!attemptId || !['pending', 'scanned', 'connecting'].includes(provision.status)) return undefined; const controller = new AbortController(); - let timer; + const scheduler = createPollScheduler({ + setTimeoutFn: (callback, delayMs) => window.setTimeout(callback, delayMs), + clearTimeoutFn: (timer) => window.clearTimeout(timer), + }); const poll = async () => { try { const result = normalizeProvisioning(await invoke( @@ -406,14 +438,20 @@ export function WeixinSettingsTab({ rpcCall }) { { attemptId }, controller.signal, )); + if (scheduler.disposed) return; if (result.status === 'connected') { - const snapshot = await loadStatus({ signal: controller.signal, silent: true }); + const snapshot = await loadStatus({ + signal: controller.signal, + silent: true, + restoreProvisioning: false, + }); + if (scheduler.disposed) return; const account = snapshot?.bots.find((bot) => bot.botId === result.botId); if (!account?.connected) { setProvision((current) => current?.attemptId === attemptId ? { ...current, ...result, status: 'connecting' } : current); - timer = window.setTimeout(poll, result.pollIntervalMs); + scheduler.schedule(poll, result.pollIntervalMs); return; } setProvision(null); @@ -426,19 +464,19 @@ export function WeixinSettingsTab({ rpcCall }) { ? { ...current, ...result, durationMs: current.durationMs } : current); if (['pending', 'scanned', 'connecting'].includes(result.status)) { - timer = window.setTimeout(poll, result.pollIntervalMs); + scheduler.schedule(poll, result.pollIntervalMs); } } catch (error) { - if (error?.name === 'AbortError') return; + if (scheduler.disposed || error?.name === 'AbortError') return; setProvision((current) => current?.attemptId === attemptId ? { ...current, status: 'failed', error: presentError(error) } : current); } }; - timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000); + scheduler.schedule(poll, provision.pollIntervalMs ?? 1_000); return () => { + scheduler.dispose(); controller.abort(); - window.clearTimeout(timer); }; }, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]); diff --git a/plugin-src/client/lifecycle.js b/plugin-src/client/lifecycle.js new file mode 100644 index 0000000..e6295ac --- /dev/null +++ b/plugin-src/client/lifecycle.js @@ -0,0 +1,86 @@ +import * as React from 'react'; + +export function createPollScheduler({ setTimeoutFn, clearTimeoutFn }) { + let disposed = false; + let timer; + + return { + get disposed() { + return disposed; + }, + schedule(callback, delayMs) { + if (disposed) return false; + if (timer !== undefined) clearTimeoutFn(timer); + timer = setTimeoutFn(() => { + timer = undefined; + if (!disposed) void callback(); + }, delayMs); + return true; + }, + dispose() { + if (disposed) return; + disposed = true; + if (timer !== undefined) clearTimeoutFn(timer); + timer = undefined; + }, + }; +} + +export function createAnimationFrameScheduler({ requestFrame, cancelFrame }) { + let disposed = false; + const frames = new Set(); + const keyedFrames = new Map(); + + return { + schedule(callback, key) { + if (disposed) return false; + const previous = key === undefined ? undefined : keyedFrames.get(key); + if (previous !== undefined) { + keyedFrames.delete(key); + frames.delete(previous); + cancelFrame(previous); + } + let frame; + let completed = false; + frame = requestFrame(() => { + completed = true; + if (frame !== undefined) frames.delete(frame); + if (key !== undefined && keyedFrames.get(key) === frame) keyedFrames.delete(key); + if (!disposed) callback(); + }); + if (!completed) { + frames.add(frame); + if (key !== undefined) keyedFrames.set(key, frame); + } + return true; + }, + dispose() { + if (disposed) return; + disposed = true; + for (const frame of frames) cancelFrame(frame); + frames.clear(); + keyedFrames.clear(); + }, + }; +} + +export function useAnimationFrameScheduler() { + const schedulerRef = React.useRef(null); + + React.useEffect(() => { + const scheduler = createAnimationFrameScheduler({ + requestFrame: (callback) => window.requestAnimationFrame(callback), + cancelFrame: (frame) => window.cancelAnimationFrame(frame), + }); + schedulerRef.current = scheduler; + return () => { + scheduler.dispose(); + if (schedulerRef.current === scheduler) schedulerRef.current = null; + }; + }, []); + + return React.useCallback( + (callback, key) => schedulerRef.current?.schedule(callback, key) ?? false, + [], + ); +} diff --git a/test/client-lifecycle.test.mjs b/test/client-lifecycle.test.mjs new file mode 100644 index 0000000..481f102 --- /dev/null +++ b/test/client-lifecycle.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { mergeFeishuSnapshotState } from '../plugin-src/client/channels/feishu/index.js'; +import { mergeWeixinProvisioningSnapshot } from '../plugin-src/client/channels/weixin/index.js'; +import { + createAnimationFrameScheduler, + createPollScheduler, +} from '../plugin-src/client/lifecycle.js'; + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function fakeTimers() { + let nextId = 1; + const pending = new Map(); + return { + setTimeout(callback) { + const id = nextId; + nextId += 1; + pending.set(id, callback); + return id; + }, + clearTimeout(id) { + pending.delete(id); + }, + runNext() { + const entry = pending.entries().next().value; + if (!entry) return false; + const [id, callback] = entry; + pending.delete(id); + callback(); + return true; + }, + get size() { + return pending.size; + }, + }; +} + +test('disposed Weixin polling cannot schedule a zombie timer after a late RPC', async () => { + const timers = fakeTimers(); + const response = deferred(); + const scheduler = createPollScheduler({ + setTimeoutFn: (callback) => timers.setTimeout(callback), + clearTimeoutFn: (id) => timers.clearTimeout(id), + }); + let polls = 0; + const poll = async () => { + polls += 1; + await response.promise; + scheduler.schedule(poll, 1_000); + }; + + scheduler.schedule(poll, 0); + assert.equal(timers.runNext(), true); + assert.equal(polls, 1); + assert.equal(timers.size, 0); + + scheduler.dispose(); + response.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(timers.size, 0); + assert.equal(scheduler.schedule(poll, 1_000), false); +}); + +test('disposing an animation-frame scheduler cancels callbacks queued by a removed tab', () => { + let nextId = 1; + const pending = new Map(); + const cancelled = []; + const scheduler = createAnimationFrameScheduler({ + requestFrame(callback) { + const id = nextId; + nextId += 1; + pending.set(id, callback); + return id; + }, + cancelFrame(id) { + cancelled.push(id); + pending.delete(id); + }, + }); + let ran = false; + + scheduler.schedule(() => { + ran = true; + }); + scheduler.dispose(); + + assert.deepEqual(cancelled, [1]); + assert.equal(pending.size, 0); + assert.equal(ran, false); + assert.equal(scheduler.schedule(() => {}), false); +}); + +test('keyed animation frames keep only the latest announcement', () => { + let nextId = 1; + const pending = new Map(); + const cancelled = []; + const scheduler = createAnimationFrameScheduler({ + requestFrame(callback) { + const id = nextId; + nextId += 1; + pending.set(id, callback); + return id; + }, + cancelFrame(id) { + cancelled.push(id); + pending.delete(id); + }, + }); + const announcements = []; + + scheduler.schedule(() => announcements.push('old'), 'announcement'); + scheduler.schedule(() => announcements.push('new'), 'announcement'); + + assert.deepEqual(cancelled, [1]); + assert.deepEqual([...pending.keys()], [2]); + const latest = pending.get(2); + pending.delete(2); + latest(); + assert.deepEqual(announcements, ['new']); + scheduler.dispose(); +}); + +test('periodic snapshots cannot restore locally cancelled Weixin or Feishu provisioning', () => { + const weixinProvisioning = { + attemptId: 'wx_attempt_stale', + status: 'pending', + expiresAt: 2_000, + }; + assert.equal(mergeWeixinProvisioningSnapshot( + null, + weixinProvisioning, + { restoreProvisioning: false }, + ), null); + assert.equal( + mergeWeixinProvisioningSnapshot( + null, + weixinProvisioning, + { restoreProvisioning: true }, + )?.attemptId, + 'wx_attempt_stale', + ); + + const current = { + phase: 'ready', + revision: 3, + bots: [], + totals: { configured: 0, connected: 0 }, + provisioning: null, + pageError: null, + statusError: null, + }; + const snapshot = { + revision: 4, + state: 'provisioning', + bots: [], + totals: { configured: 0, connected: 0 }, + provisioning: { + attemptId: 'fs_attempt_stale', + expiresAt: 2_000, + }, + }; + assert.equal(mergeFeishuSnapshotState( + current, + snapshot, + { restoreProvisioning: false, now: 1_000 }, + ).provisioning, null); + assert.equal( + mergeFeishuSnapshotState( + current, + snapshot, + { restoreProvisioning: true, now: 1_000 }, + ).provisioning.attemptId, + 'fs_attempt_stale', + ); +}); diff --git a/test/client-ui.test.mjs b/test/client-ui.test.mjs index fec4a09..f5c112f 100644 --- a/test/client-ui.test.mjs +++ b/test/client-ui.test.mjs @@ -8,6 +8,10 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { IMSettingsTab } from '../plugin-src/client/index.js'; const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url); +const CLIENT_BUNDLE_URL = new URL('../lib/client.js', import.meta.url); +const DINGTALK_CLIENT_SOURCE_URL = new URL( + import.meta.resolve('@xmanrui/dsh-dingtalk/client-source'), +); test('IM settings renders three compact logo channel tabs without enable switches', () => { const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, { @@ -42,3 +46,19 @@ test('the DingTalk QR card stacks within the narrow combined-channel panel', asy assert.match(styles, /\.dim-panel \.ddt-qrColumn \{ width: 100%; min-width: 0; \}/); assert.match(styles, /\.dim-panel \.ddt-qrCopy \{ width: 100%; min-width: 0; overflow-wrap: anywhere; \}/); }); + +test('the bundled DingTalk channel has no local sender approval workflow', async () => { + const [{ DINGTALK_ENDPOINTS }, source, bundle] = await Promise.all([ + import('@xmanrui/dsh-dingtalk/client-api'), + readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'), + readFile(CLIENT_BUNDLE_URL, 'utf8'), + ]); + + assert.equal('approveSender' in DINGTALK_ENDPOINTS, false); + assert.equal('revokeSender' in DINGTALK_ENDPOINTS, false); + assert.doesNotMatch(source, /SenderAccess|onApprove|onRevoke|approveSender|revokeSender/); + assert.doesNotMatch( + bundle, + /bot\.sender\.approve|bot\.sender\.revoke|允许使用机器人的钉钉账号|批准使用/, + ); +});