diff --git a/README.md b/README.md index 4134206..9a7ba84 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)。 ## 界面 @@ -15,6 +15,7 @@ - 飞书:扫码创建并绑定机器人,使用长连接收发消息; - 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息; - 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。 +- 企业微信:使用企业微信 App 扫码创建并授权智能机器人,通过官方 WebSocket 长连接收消息,原生显示“正在思考中”、工具执行进度和流式回答。 - QQ:使用手机 QQ 扫码创建或绑定 QQ 机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。 其他 IM 平台可继续按同一渠道适配器结构接入。 @@ -29,15 +30,17 @@ npx -y github:xmanrui/dsh-im install 钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。 +企业微信接入使用企业微信官方扫码流程,不需要手动填写 Bot ID 或 Secret。请使用已加入企业且具有机器人创建或管理权限的企业微信账号扫码,并在手机端确认创建智能机器人。扫码创建的是企业微信智能机器人,不是让插件直接登录个人微信账号。插件不设置本机二次批准流程,企业微信中的机器人可见范围就是入站访问范围,请只开放给信任的企业成员和群聊。 + QQ 接入使用腾讯 QQBot v2 官方扫码流程,不需要手动填写 AppID 或 AppSecret。默认腾讯授权页会把接入方显示为“第三方机器人”;扫码成功后创建或绑定的是 QQ 开放平台机器人,并不是让插件直接控制个人 QQ 账号。扫码者会自动成为该机器人在 Harness 中的使用者,不增加本机二次批准步骤。 ## 设计 - Harness 中只注册一个「IM机器人」设置页; -- 飞书、微信、钉钉和 QQ 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件; -- 左侧使用渠道 Logo 切换微信、飞书、钉钉和 QQ,不使用启用/停用开关; -- 四个渠道保持独立的 RPC、凭据、连接监督和会话映射; -- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret`、QQ `app_secret` 或原始用户标识。 +- 飞书、微信、钉钉、企业微信和 QQ 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件; +- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信和 QQ,不使用启用/停用开关; +- 五个渠道保持独立的 RPC、凭据、连接监督和会话映射; +- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret` 或原始用户标识。 ## 本地开发 @@ -53,9 +56,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, and QQ bots. +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. -> GitHub description: Connect IM bots to DeepSeek Harness by scanning a QR code (supports Feishu, WeChat, DingTalk, and QQ). +> GitHub description: Connect IM bots to DeepSeek Harness by scanning a QR code (supports Feishu, WeChat, DingTalk, WeCom, and QQ). ## Interface @@ -66,6 +69,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, 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. Other IM platforms can be added through the same channel-adapter structure. @@ -80,15 +84,17 @@ Restart `dsh web`, then open **Settings → Plugins → IM Bot**. The installer 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. +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. + 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. ## Design - Registers a single **IM Bot** settings page in Harness. -- Maintains the Feishu, WeChat, DingTalk, and QQ Host, client, and runtime sources in this repository without external standalone channel plugins. -- Uses channel logos for WeChat, Feishu, DingTalk, and QQ navigation without enable/disable switches. +- 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`, QQ `app_secret`, or raw user identifiers. +- 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. ## Local development diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8dd52fa..9ffd6ec 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,10 +4,12 @@ The Weixin iLink request format, QR-login states, and message fields are adapted The DingTalk device-authorization request sequence and AI Card streaming protocol are adapted from DingTalk Real Team's [`dingtalk-openclaw-connector`](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector) project at commit `b2fd6e5ea2ff99bd213faac637d3da541b2bfaf4`, licensed under the MIT License and copyright 2026 DingTalk Real Team. -This package depends on [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队. +The WeCom QR-authorization request sequence is adapted from the official [`@wecom/wecom-openclaw-cli`](https://www.npmjs.com/package/@wecom/wecom-openclaw-cli) 1.1.0 package, whose npm metadata declares the ISC License. No CLI source or OpenClaw runtime is bundled in this package. + +This package depends on [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) 1.0.7, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队. QQ QR binding uses Tencent Connect's official [`@tencent-connect/qqbot-connector`](https://www.npmjs.com/package/@tencent-connect/qqbot-connector) 1.2.0 package as an external runtime dependency. Its npm metadata declares `UNLICENSED`; no connector source is copied into this project. -This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, Feishu, DingTalk, or QQ. +This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, WeCom, Feishu, DingTalk, or QQ. -The WeChat and QQ marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu and DingTalk marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners. +The WeChat and QQ marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu, DingTalk, and WeCom marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners. diff --git a/lib/client.js b/lib/client.js index 30d8fcc..302b60f 100644 --- a/lib/client.js +++ b/lib/client.js @@ -40,7 +40,7 @@ __export(index_exports, { name: () => name }); module.exports = __toCommonJS(index_exports); -var React7 = __toESM(require("react"), 1); +var React8 = __toESM(require("react"), 1); // plugin-src/client/channel-logos.js var React = __toESM(require("react"), 1); @@ -99,6 +99,30 @@ function QqLogoGlyph({ size } = {}) { d: "M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673" })); } +function WecomLogoGlyph({ size } = {}) { + return h( + "svg", + { + ...dimensions(size), + viewBox: "0 0 24 24", + focusable: "false", + "aria-hidden": "true", + "data-im-channel-logo": "wecom" + }, + h("path", { + fill: "none", + stroke: "#3370FF", + strokeWidth: "2.35", + strokeLinecap: "round", + strokeLinejoin: "round", + d: "M17.7 14.5c1.05-1.12 1.65-2.52 1.65-4.03 0-3.82-3.58-6.92-8-6.92s-8 3.1-8 6.92 3.58 6.92 8 6.92c1.17 0 2.28-.22 3.28-.62" + }), + h("path", { fill: "#07C160", d: "M16.1 15.15c.7-.7 1.83-.7 2.53 0s.7 1.83 0 2.53-1.83.7-2.53 0-.7-1.83 0-2.53Z" }), + h("path", { fill: "#FFB800", d: "M19.25 13.45a1.36 1.36 0 1 1 1.92 1.92 1.36 1.36 0 0 1-1.92-1.92Z" }), + h("path", { fill: "#FF7A00", d: "M19.55 18.05a1.16 1.16 0 1 1 1.64 1.64 1.16 1.16 0 0 1-1.64-1.64Z" }), + h("path", { fill: "#3370FF", d: "M15.25 18.75a.92.92 0 1 1 1.3 1.3.92.92 0 0 1-1.3-1.3Z" }) + ); +} // plugin-src/client/channels/dingtalk/api.js var DINGTALK_RPC_CHANNEL = "/dingtalk"; @@ -137,8 +161,8 @@ function optionalString(value, maxLength = 240) { return trimmed ? trimmed.slice(0, maxLength) : void 0; } function opaqueId(value) { - const id2 = optionalString(value, 128); - return id2 && /^[a-z\d_-]+$/i.test(id2) ? id2 : void 0; + const id3 = optionalString(value, 128); + return id3 && /^[a-z\d_-]+$/i.test(id3) ? id3 : void 0; } function timestamp(value) { if (typeof value === "number" && Number.isFinite(value)) return value; @@ -2243,14 +2267,14 @@ var HEALTH_LABELS = { offline: "\u8FDE\u63A5\u4E2D\u65AD", error: "\u9700\u8981\u5904\u7406" }; -function formatCheckedTime(timestamp4) { - if (!timestamp4) return "\u5C1A\u672A\u68C0\u67E5"; +function formatCheckedTime(timestamp5) { + if (!timestamp5) return "\u5C1A\u672A\u68C0\u67E5"; try { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" - }).format(new Date(timestamp4)); + }).format(new Date(timestamp5)); } catch { return "\u521A\u521A"; } @@ -2585,14 +2609,14 @@ function FeishuSettingsTab({ rpcCall }) { FEISHU_ENDPOINTS.beginProvisioning, { locale: "zh-CN" } )); - const timestamp4 = Date.now(); - setNow(timestamp4); + const timestamp5 = Date.now(); + setNow(timestamp5); setModel((current) => ({ ...current, provisioning: { phase: "qr", ...provision2, - durationMs: Math.max(1, provision2.expiresAt - timestamp4), + durationMs: Math.max(1, provision2.expiresAt - timestamp5), expired: false } })); @@ -2631,9 +2655,9 @@ function FeishuSettingsTab({ rpcCall }) { React4.useEffect(() => { if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return void 0; const tick = () => { - const timestamp4 = Date.now(); - setNow(timestamp4); - if (timestamp4 >= countdownExpiresAt) { + const timestamp5 = Date.now(); + setNow(timestamp5); + if (timestamp5 >= countdownExpiresAt) { setModel((current) => current.provisioning?.attemptId === countdownAttemptId ? { ...current, provisioning: { ...current.provisioning, expired: true } } : current); } }; @@ -3427,9 +3451,574 @@ function QqSettingsTab({ rpcCall }) { ); } -// plugin-src/client/channels/weixin/index.js +// plugin-src/client/channels/wecom/api.js +var WECOM_RPC_CHANNEL = "/wecom"; +var WECOM_ENDPOINTS = Object.freeze({ + status: "connection.status", + beginProvisioning: "provision.begin", + pollProvisioning: "provision.poll", + cancelProvisioning: "provision.cancel", + reconnectBot: "bot.reconnect", + deleteBot: "bot.delete" +}); +var PROVISION_STATES3 = /* @__PURE__ */ new Set(["starting", "pending", "refreshing", "connecting", "connected", "failed", "cancelled"]); +var ACCOUNT_STATES3 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); +var QR_DATA_URL3 = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i; +function isRecord4(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function text2(value, fallback, max = 240) { + return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : fallback; +} +function id2(value) { + const result = text2(value, "", 128); + return /^[a-z\d_-]+$/i.test(result) ? result : void 0; +} +function timestamp3(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN; + return Number.isNaN(parsed) ? void 0 : parsed; +} +function unwrapRpcResult4(result) { + if (!isRecord4(result) || typeof result.ok !== "boolean") throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94"); + if (!result.ok) { + const error = new Error(text2(result.error?.message, "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25")); + error.code = text2(result.error?.code, "WECOM_RPC_ERROR", 80); + throw error; + } + return result.value; +} +function safeQrSource4(value) { + return typeof value === "string" && value.length <= 2 * 1024 * 1024 && QR_DATA_URL3.test(value) ? value : void 0; +} +function normalizeProvisioning4(value, now = Date.now()) { + const source = isRecord4(value?.provisioning) ? value.provisioning : value; + if (!isRecord4(source)) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u626B\u7801\u7ED1\u5B9A\u8FDB\u5EA6"); + const attemptId = id2(source.attemptId); + if (!attemptId) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u626B\u7801\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u7ED1\u5B9A\u4EFB\u52A1"); + const reported = text2(source.status, "failed", 32); + const result = { + attemptId, + status: PROVISION_STATES3.has(reported) ? reported : "failed", + expiresAt: timestamp3(source.expiresAt) ?? now + 5 * 6e4, + pollIntervalMs: Math.min(1e4, Math.max(500, Number(source.pollIntervalMs) || 1e3)), + qrRevision: Number.isSafeInteger(source.qrRevision) ? source.qrRevision : 0 + }; + const qrCodeDataUrl = safeQrSource4(source.qrCodeDataUrl); + if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl; + if (id2(source.botId)) result.botId = id2(source.botId); + if (isRecord4(source.error)) result.error = { + code: text2(source.error.code, "WECOM_PROVISION_FAILED", 80), + message: text2(source.error.message, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210") + }; + return result; +} +function normalizeBot4(value) { + if (!isRecord4(value) || !id2(value.botId)) return void 0; + const connected = value.connected === true; + const state = ACCOUNT_STATES3.has(value.state) ? value.state : "offline"; + return { + botId: id2(value.botId), + connected, + state: connected ? "connected" : state, + bot: { + name: text2(value.bot?.name, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA", 100), + appIdMasked: text2(value.bot?.appIdMasked, "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140) + }, + health: { + summary: text2(value.health?.summary, connected ? "\u4F01\u4E1A\u5FAE\u4FE1 WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"), + lastCheckedAt: timestamp3(value.health?.lastCheckedAt) + }, + error: isRecord4(value.error) ? { + code: text2(value.error.code, "WECOM_ACCOUNT_ERROR", 80), + message: text2(value.error.message, "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA") + } : null + }; +} +function normalizeSnapshot3(value) { + const source = isRecord4(value?.snapshot) ? value.snapshot : value; + if (!isRecord4(source) || !Array.isArray(source.bots)) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u673A\u5668\u4EBA\u5217\u8868"); + const bots = source.bots.map(normalizeBot4).filter(Boolean); + return { + revision: Number.isSafeInteger(source.revision) ? source.revision : 0, + bots, + totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length }, + provisioning: source.provisioning ? normalizeProvisioning4(source.provisioning) : null + }; +} +function presentError4(error) { + return { + code: text2(error?.code, "WECOM_ERROR", 80), + message: text2(error?.message, "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") + }; +} +function formatRemaining4(milliseconds) { + const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1e3) || 0); + return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; +} + +// plugin-src/client/channels/wecom/index.js var React6 = __toESM(require("react"), 1); +// plugin-src/client/channels/wecom/styles.js +var WECOM_STYLE_ID = "xmanrui-dsh-im-wecom-settings"; +var CSS4 = String.raw` +.dwecom-page { --ddt-accent: #3370ff; --ddt-accent-deep: #245bdb; --ddt-accent-wash: #eef4ff; } +.dwecom-avatar, .dwecom-brand { color: #3370ff; background: #fff; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); } +.dwecom-avatar svg, .dwecom-brand svg { display: block; } +`; +function installWecomStyles() { + if (typeof document === "undefined") return () => { + }; + const existing = document.querySelector(`style[data-plugin-css="${WECOM_STYLE_ID}"]`); + if (existing) return () => { + }; + const style = document.createElement("style"); + style.dataset.plugin = "@xmanrui/dsh-im"; + style.dataset.pluginCss = WECOM_STYLE_ID; + style.textContent = CSS4; + document.head.appendChild(style); + return () => style.remove(); +} + +// plugin-src/client/channels/wecom/index.js +var h5 = React6.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", { + ...props, + ref, + type: "button", + className: `ddt-button ${className}`.trim(), + "data-kind": kind + }, children); +}); +function checkedTime3(value) { + if (!value) return "\u5C1A\u672A\u68C0\u67E5"; + try { + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }).format(new Date(value)); + } catch { + return "\u521A\u521A"; + } +} +function Heading4({ totals, adding, busy, onAdd, addButtonRef }) { + return h5( + "div", + { className: "ddt-heading" }, + h5( + "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( + "div", + { className: "ddt-badge dim-onlineBadge" }, + h5("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) + ) : null + ) + ); +} +function LoadingView4() { + return h5( + "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") + ); +} +function EmptyView4({ busy, onStart }) { + return h5( + "div", + { className: "ddt-card dim-surfaceCard" }, + h5( + "div", + { className: "ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView" }, + h5( + "div", + { className: "dim-emptyCopy" }, + h5( + "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") + ), + 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( + "div", + { className: "ddt-actions dim-viewActions" }, + h5( + 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( + "div", + { className: "ddt-brandMark dim-emptyBrand dwecom-brand", "aria-hidden": "true" }, + h5(WecomLogoGlyph, { size: 64 }) + ) + ) + ); +} +function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { + const source = safeQrSource4(provision.qrCodeDataUrl); + const remaining = Math.max(0, provision.expiresAt - now); + 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( + "div", + { className: "ddt-card dim-surfaceCard" }, + h5( + "div", + { className: "ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout" }, + h5( + "div", + { className: "ddt-qrColumn dim-qrColumn" }, + h5( + "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( + "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( + "div", + { className: "ddt-countdown dim-countdown" }, + h5( + "div", + { className: "ddt-countdownTop dim-countdownTop" }, + h5("span", null, "\u5F53\u524D\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), + h5("strong", null, refreshing ? "--:--" : formatRemaining4(remaining)) + ), + h5("div", { className: "ddt-progress dim-progress", style: { "--ddt-progress": `${progress}%` } }, h5("span")) + ) + ), + h5( + "div", + { className: "ddt-qrCopy dim-qrCopy" }, + h5( + "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") + ), + 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( + "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") + ), + h5( + "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") + ) + ) + ) + ); +} +function ProvisionView2({ provision, busy, onRetry, onClose }) { + if (provision.status === "connecting") { + return h5( + "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") + ); + } + const error = provision.error ?? { code: "WECOM_PROVISION_FAILED", message: "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210" }; + return h5( + "div", + { className: "ddt-card dim-surfaceCard" }, + h5( + "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( + "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") + ) + ) + ); +} +function RemoveConfirmation4({ account, busy, onConfirm, onCancel }) { + return h5( + "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( + "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") + ) + ); +} +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( + "article", + { className: "ddt-card dim-botCard", "data-bot-id": account.botId }, + h5( + "div", + { className: "ddt-cardBody dim-botCardBody" }, + h5( + "div", + { className: "ddt-accountTop dim-botCardTop" }, + h5( + "div", + { className: "ddt-accountIdentity dim-botIdentity" }, + h5("div", { className: "ddt-avatar dim-botAvatar dwecom-avatar", "aria-hidden": "true" }, h5(WecomLogoGlyph, { size: 29 })), + h5( + "div", + { className: "dim-botName" }, + h5("h3", null, account.bot.name), + h5("p", null, account.bot.appIdMasked) + ) + ), + h5( + "div", + { className: "ddt-health dim-botHealth" }, + h5("span", { className: "ddt-dot dim-healthDot", "data-tone": tone }), + h5("span", null, stateLabel) + ) + ), + h5( + "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))) + ), + h5( + "div", + { className: "ddt-accountFooter dim-cardFooter" }, + h5("div", { className: "ddt-summary dim-cardSummary" }, account.error?.message ?? account.health.summary), + h5( + "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") + ) + ) + ), + removing ? h5(RemoveConfirmation4, { + account, + busy: busy === "delete", + onConfirm: onConfirmRemove, + onCancel: onCancelRemove + }) : null + ); +} +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 disposeDingtalk = installDingtalkStyles(); + const disposeWecom = installWecomStyles(); + mounted.current = true; + return () => { + mounted.current = false; + disposeWecom(); + disposeDingtalk(); + }; + }, []); + const invoke = React6.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 } = {}) => { + if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); + try { + const snapshot = normalizeSnapshot3(await invoke(WECOM_ENDPOINTS.status, {}, signal)); + if (!mounted.current || signal?.aborted) return void 0; + setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); + if (restore && snapshot.provisioning) setProvision({ + ...snapshot.provisioning, + durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()) + }); + return snapshot; + } catch (error) { + if (error?.name !== "AbortError" && mounted.current && !signal?.aborted) { + setModel((current) => ({ ...current, phase: silent ? current.phase : "error", error: presentError4(error) })); + } + return void 0; + } + }, [invoke]); + React6.useEffect(() => { + const controller = new AbortController(); + void loadStatus({ signal: controller.signal, restore: true }); + return () => controller.abort(); + }, [loadStatus]); + 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); + return () => { + controller.abort(); + window.clearInterval(timer); + }; + }, [loadStatus, model.phase]); + React6.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) => { + setBusy(true); + try { + if (replace && provision?.attemptId) await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + if (!mounted.current) return; + setProvision({ status: "starting" }); + const started = normalizeProvisioning4(await invoke(WECOM_ENDPOINTS.beginProvisioning, { locale: "zh-CN" })); + if (!mounted.current) return; + setNow(Date.now()); + setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) }); + } catch (error) { + if (mounted.current) setProvision({ status: "failed", error: presentError4(error) }); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke, provision?.attemptId]); + const closeProvision = React6.useCallback(async () => { + setBusy(true); + try { + if (provision?.attemptId && ACTIVE_STATES2.has(provision.status)) { + await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + } + if (mounted.current) setProvision(null); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke, provision?.attemptId, provision?.status]); + React6.useEffect(() => { + const attemptId = provision?.attemptId; + if (!attemptId || !ACTIVE_STATES2.has(provision.status)) return void 0; + const controller = new AbortController(); + let disposed = false; + let timer; + const poll = async () => { + try { + const current = normalizeProvisioning4(await invoke(WECOM_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal)); + if (disposed || controller.signal.aborted || !mounted.current) return; + if (current.status === "connected") { + setProvision(null); + await loadStatus({ signal: controller.signal, silent: true }); + return; + } + setProvision((previous) => previous?.attemptId === attemptId ? { ...previous, ...current, durationMs: current.qrRevision !== previous.qrRevision ? Math.max(1, current.expiresAt - Date.now()) : previous.durationMs } : previous); + if (ACTIVE_STATES2.has(current.status)) timer = window.setTimeout(poll, current.pollIntervalMs); + } catch (error) { + if (!disposed && !controller.signal.aborted && mounted.current) { + setProvision((current) => ({ ...current, status: "failed", error: presentError4(error) })); + } + } + }; + timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1e3); + return () => { + disposed = true; + controller.abort(); + window.clearTimeout(timer); + }; + }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); + const botAction = React6.useCallback(async (account, operation, endpoint, payload) => { + setBusyByBot((current) => ({ ...current, [account.botId]: operation })); + try { + const snapshot = normalizeSnapshot3(await invoke(endpoint, payload)); + if (mounted.current) setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); + return snapshot; + } finally { + if (mounted.current) setBusyByBot((current) => { + const next = { ...current }; + delete next[account.botId]; + return next; + }); + } + }, [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, { + provision, + now, + busy, + onRefresh: () => void startProvisioning(true), + onCancel: () => void closeProvision() + }); + else if (provision) provisionView = h5(ProvisionView2, { + provision, + busy, + onRetry: () => void startProvisioning(true), + onClose: () => void closeProvision() + }); + const botList = model.bots.length > 0 ? h5( + "section", + { className: "dim-listSection" }, + h5( + "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`) + ), + h5("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h5("li", { key: account.botId }, h5(AccountCard3, { + account, + busy: busyByBot[account.botId], + removing: removeTarget === account.botId, + onReconnect: () => void botAction(account, "reconnect", WECOM_ENDPOINTS.reconnectBot, { botId: account.botId }), + onRequestRemove: () => setRemoveTarget(account.botId), + onCancelRemove: () => setRemoveTarget(null), + onConfirmRemove: async () => { + await botAction(account, "delete", WECOM_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true }); + if (mounted.current) setRemoveTarget(null); + } + })))) + ) : null; + return h5( + "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, + null, + provisionView, + model.bots.length === 0 && !provision ? h5(EmptyView4, { busy, onStart: () => void startProvisioning() }) : null, + botList + ) + ); +} + +// plugin-src/client/channels/weixin/index.js +var React7 = __toESM(require("react"), 1); + // plugin-src/client/channels/weixin/api.js var WEIXIN_RPC_CHANNEL = "/weixin"; var WEIXIN_ENDPOINTS = Object.freeze({ @@ -3441,8 +4030,8 @@ var WEIXIN_ENDPOINTS = Object.freeze({ reconnectBot: "bot.reconnect", deleteBot: "bot.delete" }); -var ACCOUNT_STATES3 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); -var PROVISION_STATES3 = /* @__PURE__ */ new Set([ +var ACCOUNT_STATES4 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); +var PROVISION_STATES4 = /* @__PURE__ */ new Set([ "starting", "pending", "scanned", @@ -3453,17 +4042,17 @@ var PROVISION_STATES3 = /* @__PURE__ */ new Set([ "failed", "cancelled" ]); -function isRecord4(value) { +function isRecord5(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function string(value, fallback = "") { return typeof value === "string" && value.trim() ? value.trim() : fallback; } -function timestamp3(value) { +function timestamp4(value) { return typeof value === "number" && Number.isFinite(value) ? value : null; } -function unwrapRpcResult4(result) { - if (!isRecord4(result) || typeof result.ok !== "boolean") { +function unwrapRpcResult5(result) { + if (!isRecord5(result) || typeof result.ok !== "boolean") { throw new Error("\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94"); } if (!result.ok) { @@ -3473,7 +4062,7 @@ function unwrapRpcResult4(result) { } return result.value; } -function safeQrSource4(value) { +function safeQrSource5(value) { return typeof value === "string" && /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value) ? value : void 0; } function safeVerificationUrl(value) { @@ -3486,25 +4075,25 @@ function safeVerificationUrl(value) { return void 0; } } -function normalizeProvisioning4(value) { - if (!isRecord4(value) || !string(value.attemptId)) { +function normalizeProvisioning5(value) { + if (!isRecord5(value) || !string(value.attemptId)) { throw new Error("\u5FAE\u4FE1\u626B\u7801\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u7ED1\u5B9A\u4EFB\u52A1"); } - const status = PROVISION_STATES3.has(value.status) ? value.status : "failed"; + const status = PROVISION_STATES4.has(value.status) ? value.status : "failed"; const result = { attemptId: string(value.attemptId), status, - expiresAt: timestamp3(value.expiresAt) ?? Date.now(), + expiresAt: timestamp4(value.expiresAt) ?? Date.now(), pollIntervalMs: Math.min(5e3, Math.max(500, Number(value.pollIntervalMs) || 1e3)), verificationRequired: value.verificationRequired === true || status === "needs_verification" }; const verificationUrl = safeVerificationUrl(value.verificationUrl); - const qrCodeDataUrl = safeQrSource4(value.qrCodeDataUrl); + const qrCodeDataUrl = safeQrSource5(value.qrCodeDataUrl); if (verificationUrl) result.verificationUrl = verificationUrl; if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl; if (string(value.botId)) result.botId = string(value.botId); if (value.alreadyConnected === true) result.alreadyConnected = true; - if (isRecord4(value.error)) { + if (isRecord5(value.error)) { result.error = { code: string(value.error.code, "WEIXIN_PROVISION_FAILED"), message: string(value.error.message, "\u5FAE\u4FE1\u7ED1\u5B9A\u6CA1\u6709\u5B8C\u6210") @@ -3512,9 +4101,9 @@ function normalizeProvisioning4(value) { } return result; } -function normalizeBot4(value) { - if (!isRecord4(value) || !string(value.botId) || !isRecord4(value.bot)) return null; - const state = ACCOUNT_STATES3.has(value.state) ? value.state : "error"; +function normalizeBot5(value) { + if (!isRecord5(value) || !string(value.botId) || !isRecord5(value.bot)) return null; + const state = ACCOUNT_STATES4.has(value.state) ? value.state : "error"; const connected = value.connected === true; return { botId: string(value.botId), @@ -3528,23 +4117,23 @@ function normalizeBot4(value) { health: { status: string(value.health?.status, connected ? "healthy" : "offline"), summary: string(value.health?.summary, connected ? "\u5FAE\u4FE1\u8FDE\u63A5\u6B63\u5E38" : "\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA"), - lastCheckedAt: timestamp3(value.health?.lastCheckedAt) + lastCheckedAt: timestamp4(value.health?.lastCheckedAt) }, stats: { messagesReceived: Math.max(0, Number(value.stats?.messagesReceived) || 0), messagesReplied: Math.max(0, Number(value.stats?.messagesReplied) || 0) }, - error: isRecord4(value.error) ? { + error: isRecord5(value.error) ? { code: string(value.error.code, "WEIXIN_ACCOUNT_ERROR"), message: string(value.error.message, "\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA") } : null }; } -function normalizeSnapshot3(value) { - if (!isRecord4(value) || !Array.isArray(value.bots)) { +function normalizeSnapshot4(value) { + if (!isRecord5(value) || !Array.isArray(value.bots)) { throw new Error("\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u8D26\u53F7\u5217\u8868"); } - const bots = value.bots.map(normalizeBot4).filter(Boolean); + const bots = value.bots.map(normalizeBot5).filter(Boolean); return { schemaVersion: Number(value.schemaVersion) || 1, revision: Number(value.revision) || 0, @@ -3554,23 +4143,23 @@ function normalizeSnapshot3(value) { configured: bots.length, connected: bots.filter((bot) => bot.connected).length }, - provisioning: value.provisioning ? normalizeProvisioning4(value.provisioning) : null + provisioning: value.provisioning ? normalizeProvisioning5(value.provisioning) : null }; } -function presentError4(error) { +function presentError5(error) { return { code: string(error?.code, "WEIXIN_ERROR"), message: string(error?.message, "\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") }; } -function formatRemaining4(milliseconds) { +function formatRemaining5(milliseconds) { const seconds = Math.max(0, Math.ceil(milliseconds / 1e3)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; } // plugin-src/client/channels/weixin/styles.js var WEIXIN_STYLE_ID = "xmanrui-dsh-weixin-settings"; -var CSS4 = String.raw` +var CSS5 = String.raw` .dxw-page { --dxw-accent: #07c160; --dxw-accent-dark: #05994c; @@ -3686,15 +4275,15 @@ function installWeixinStyles() { const style = document.createElement("style"); style.dataset.plugin = "@xmanrui/dsh-weixin"; style.dataset.pluginCss = WEIXIN_STYLE_ID; - style.textContent = CSS4; + style.textContent = CSS5; document.head.appendChild(style); return () => style.remove(); } // plugin-src/client/channels/weixin/index.js -var h5 = React6.createElement; -var Button7 = React6.forwardRef(function Button8({ children, kind = "secondary", className = "", ...props }, ref) { - return h5("button", { +var h6 = React7.createElement; +var Button9 = React7.forwardRef(function Button10({ children, kind = "secondary", className = "", ...props }, ref) { + return h6("button", { ...props, ref, type: "button", @@ -3702,150 +4291,150 @@ var Button7 = React6.forwardRef(function Button8({ children, kind = "secondary", "data-kind": kind }, children); }); -function Heading4({ totals, adding, busy, onAdd, addButtonRef }) { - return h5( +function Heading5({ totals, adding, busy, onAdd, addButtonRef }) { + return h6( "div", { className: "dxw-heading" }, - h5( + h6( "div", { className: "dxw-tools" }, - h5(Button7, { + h6(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 ? h5( + totals.configured > 0 ? h6( "div", { className: "dxw-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( +function LoadingView5() { + return h6( "div", { className: "dxw-card dxw-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h5("div", { className: "dxw-spinner dim-spinner" }), - h5("span", null, "\u6B63\u5728\u8BFB\u53D6\u5FAE\u4FE1\u8FDE\u63A5\u72B6\u6001\u2026") + h6("div", { className: "dxw-spinner dim-spinner" }), + h6("span", null, "\u6B63\u5728\u8BFB\u53D6\u5FAE\u4FE1\u8FDE\u63A5\u72B6\u6001\u2026") ); } -function EmptyView4({ onStart, busy }) { - return h5( +function EmptyView5({ onStart, busy }) { + return h6( "div", { className: "dxw-card dim-surfaceCard" }, - h5( + h6( "div", { className: "dxw-cardBody dxw-empty dim-surfaceBody dim-emptyView" }, - h5( + h6( "div", { className: "dim-emptyCopy" }, - h5( + h6( "div", { className: "dxw-stateLabel dim-stateLabel" }, - h5("span", { className: "dxw-dot dim-stateDot" }), - h5("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u5FAE\u4FE1") + h6("span", { className: "dxw-dot dim-stateDot" }), + h6("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u5FAE\u4FE1") ), - h5("h3", null, "\u626B\u4E00\u6B21\u7801\uFF0C\u5C31\u80FD\u5728\u5FAE\u4FE1\u91CC\u4F7F\u7528 Harness"), - h5("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"), - h5( + 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( "div", { className: "dxw-actions dim-viewActions" }, - h5( - Button7, + h6( + Button9, { kind: "primary", onClick: onStart, disabled: busy }, busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210\u5FAE\u4FE1\u4E8C\u7EF4\u7801" ) ) ), - h5("div", { className: "dxw-logo dim-emptyBrand", "aria-hidden": "true" }, h5(WeixinLogoGlyph, { size: 64 })) + h6("div", { className: "dxw-logo dim-emptyBrand", "aria-hidden": "true" }, h6(WeixinLogoGlyph, { size: 64 })) ) ); } -function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { - const [imageFailed, setImageFailed] = React6.useState(false); - const source = safeQrSource4(provision.qrCodeDataUrl); +function QrPanel4({ provision, now, busy, onRefresh, onCancel }) { + const [imageFailed, setImageFailed] = React7.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); - React6.useEffect(() => setImageFailed(false), [source]); - return h5( + React7.useEffect(() => setImageFailed(false), [source]); + return h6( "div", { className: "dxw-card dim-surfaceCard" }, - h5( + h6( "div", { className: "dxw-cardBody dxw-qrLayout dim-surfaceBody dim-qrLayout" }, - h5( + h6( "div", { className: "dxw-qrColumn dim-qrColumn" }, - h5( + h6( "div", { className: "dxw-qrFrame dim-qrFrame" }, - source && !imageFailed ? h5("img", { + source && !imageFailed ? h6("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) - }) : h5("div", { className: "dxw-qrFallback dim-qrFallback" }, "\u4E8C\u7EF4\u7801\u56FE\u7247\u672A\u5C31\u7EEA\uFF0C\u8BF7\u4F7F\u7528\u5907\u7528\u94FE\u63A5\u3002"), - expired ? h5("div", { className: "dxw-expired dim-qrExpired" }, "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\n\u8BF7\u91CD\u65B0\u751F\u6210") : null + }) : 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 ), - h5( + h6( "div", { className: "dxw-countdown dim-countdown" }, - h5("div", { className: "dim-countdownTop" }, h5("span", null, "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), h5("strong", null, formatRemaining4(remaining))), - h5( + h6("div", { className: "dim-countdownTop" }, h6("span", null, "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), h6("strong", null, formatRemaining5(remaining))), + h6( "div", { className: "dxw-progress dim-progress", "aria-hidden": "true" }, - h5("span", { style: { "--dxw-progress": `${progress}%` } }) + h6("span", { style: { "--dxw-progress": `${progress}%` } }) ) ) ), - h5( + h6( "div", { className: "dxw-qrCopy dim-qrCopy" }, - h5( + h6( "div", { className: "dxw-stateLabel dim-stateLabel" }, - h5("span", { className: "dxw-dot dim-stateDot", "data-tone": provision.status === "scanned" ? "success" : "warning" }), - h5("span", null, provision.status === "scanned" ? "\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u4E0A\u786E\u8BA4" : "\u7B49\u5F85\u5FAE\u4FE1\u626B\u7801") + 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") ), - h5("h3", null, expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : "\u4F7F\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u4E8C\u7EF4\u7801"), - h5("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"), - h5( + 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( "ol", { className: "dxw-steps dim-steps" }, - h5("li", null, "\u6253\u5F00\u624B\u673A\u5FAE\u4FE1\u5E76\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), - h5("li", null, "\u5728\u5FAE\u4FE1\u4E2D\u786E\u8BA4\u8FDE\u63A5\u8BE5\u673A\u5668\u4EBA"), - h5("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u6D88\u606F\u957F\u8F6E\u8BE2\u53D8\u4E3A\u5728\u7EBF") + 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") ), - h5( + h6( "div", { className: "dxw-actions dim-viewActions" }, - expired ? h5(Button7, { kind: "primary", onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801") : null, - href ? h5("a", { + expired ? h6(Button9, { kind: "primary", onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801") : null, + href ? h6("a", { className: "dxw-button", href, target: "_blank", rel: "noopener noreferrer" }, "\u6253\u5F00\u5907\u7528\u94FE\u63A5") : null, - !expired ? h5(Button7, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, - h5(Button7, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + !expired ? h6(Button9, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, + h6(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") ) ) ) ); } function VerificationPanel({ provision, busy, onSubmit, onCancel }) { - const [code, setCode] = React6.useState(""); + const [code, setCode] = React7.useState(""); const valid = /^\d{4,8}$/.test(code); - React6.useEffect(() => setCode(""), [provision.attemptId]); - return h5( + React7.useEffect(() => setCode(""), [provision.attemptId]); + return h6( "div", { className: "dxw-card dim-surfaceCard" }, - h5( + h6( "form", { className: "dxw-verify dim-specialView", @@ -3854,18 +4443,18 @@ function VerificationPanel({ provision, busy, onSubmit, onCancel }) { if (valid && !busy) onSubmit(code); } }, - h5( + h6( "div", { className: "dxw-stateLabel" }, - h5("span", { className: "dxw-dot", "data-tone": "warning" }), - h5("span", null, "\u9700\u8981\u914D\u5BF9\u7801") + h6("span", { className: "dxw-dot", "data-tone": "warning" }), + h6("span", null, "\u9700\u8981\u914D\u5BF9\u7801") ), - h5("h3", null, "\u8F93\u5165\u624B\u673A\u5FAE\u4FE1\u663E\u793A\u7684\u6570\u5B57"), - h5("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"), - h5( + 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( "div", { className: "dxw-codeRow" }, - h5("input", { + h6("input", { className: "dxw-input", value: code, inputMode: "numeric", @@ -3875,131 +4464,131 @@ function VerificationPanel({ provision, busy, onSubmit, onCancel }) { onChange: (event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 8)), autoFocus: true }), - h5("button", { + h6("button", { type: "submit", className: "dxw-button", "data-kind": "primary", disabled: !valid || busy }, busy ? "\u6B63\u5728\u9A8C\u8BC1\u2026" : "\u7EE7\u7EED\u8FDE\u63A5") ), - h5(Button7, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u7ED1\u5B9A") + h6(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u7ED1\u5B9A") ) ); } function ProgressPanel2({ scanned, onCancel, busy }) { - return h5( + return h6( "div", { className: "dxw-card dxw-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h5("div", { className: "dxw-spinner dim-spinner" }), - h5("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"), - h5("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 ? h5( + 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( "div", { className: "dxw-actions dim-viewActions", style: { justifyContent: "center", marginTop: 14 } }, - h5(Button7, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + h6(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 h5( + return h6( "div", { className: "dxw-card dim-surfaceCard" }, - h5( + h6( "div", { className: "dxw-error dim-inlineError", role: "alert" }, - h5("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : "\u5FAE\u4FE1\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), - h5("p", null, error.message), - h5("span", { className: "dxw-errorCode" }, error.code), - h5( + 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( "div", { className: "dxw-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(Button9, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h6(Button9, { onClick: onClose, disabled: busy }, "\u5173\u95ED") ) ) ); } -function checkedTime3(timestamp4) { - if (!timestamp4) return "\u5C1A\u672A\u68C0\u67E5"; +function checkedTime4(timestamp5) { + if (!timestamp5) return "\u5C1A\u672A\u68C0\u67E5"; try { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" - }).format(new Date(timestamp4)); + }).format(new Date(timestamp5)); } catch { return "\u521A\u521A"; } } -function AccountCard3({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { +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 h5( + return h6( "article", { className: "dxw-card dim-botCard", tabIndex: -1, "data-bot-id": account.botId }, - h5( + h6( "div", { className: "dxw-cardBody dim-botCardBody" }, - h5( + h6( "div", { className: "dxw-accountTop dim-botCardTop" }, - h5( + h6( "div", { className: "dxw-accountIdentity dim-botIdentity" }, - h5("div", { className: "dxw-avatar dim-botAvatar", "aria-hidden": "true" }, h5(WeixinLogoGlyph, { size: 27 })), - h5("div", { className: "dim-botName" }, h5("h3", null, account.bot.name), h5("p", null, account.bot.accountIdMasked)) + 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)) ), - h5( + h6( "div", { className: "dxw-health dim-botHealth" }, - h5("span", { className: "dxw-dot dim-healthDot", "data-tone": tone }), - h5("span", null, account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA") + 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") ) ), - h5( + h6( "dl", { className: "dxw-metrics dim-botMetrics" }, - h5( + h6( "div", { className: "dxw-metric dim-botMetric" }, - h5("dt", null, "\u6D88\u606F\u901A\u9053"), - h5("dd", null, account.connected ? "iLink \u957F\u8F6E\u8BE2" : "\u79BB\u7EBF") + h6("dt", null, "\u6D88\u606F\u901A\u9053"), + h6("dd", null, account.connected ? "iLink \u957F\u8F6E\u8BE2" : "\u79BB\u7EBF") ), - h5( + h6( "div", { className: "dxw-metric dim-botMetric" }, - h5("dt", null, "\u6700\u8FD1\u68C0\u67E5"), - h5("dd", null, checkedTime3(account.health.lastCheckedAt)) + h6("dt", null, "\u6700\u8FD1\u68C0\u67E5"), + h6("dd", null, checkedTime4(account.health.lastCheckedAt)) ) ), - h5( + h6( "div", { className: "dxw-accountFooter dim-cardFooter" }, - h5("div", { className: "dxw-summary dim-cardSummary" }, account.error?.message ?? account.health.summary), - h5( + h6("div", { className: "dxw-summary dim-cardSummary" }, account.error?.message ?? account.health.summary), + h6( "div", { className: "dxw-actions dim-cardActions" }, - h5( - Button7, + h6( + 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" ), - h5(Button7, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") + h6(Button9, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") ) ) ), - removing ? h5( + removing ? h6( "div", { className: "dxw-confirm dim-confirm", role: "alertdialog" }, - h5("strong", null, "\u4ECE\u6B64 Harness \u79FB\u9664\u8FD9\u4E2A\u5FAE\u4FE1\u8D26\u53F7\uFF1F"), - h5("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"), - h5( + 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( "div", { className: "dxw-actions dim-viewActions" }, - h5(Button7, { onClick: onCancelRemove, disabled: busy === "delete" }, "\u4FDD\u7559\u8D26\u53F7"), - h5( - Button7, + h6(Button9, { onClick: onCancelRemove, disabled: busy === "delete" }, "\u4FDD\u7559\u8D26\u53F7"), + h6( + Button9, { kind: "danger", onClick: onConfirmRemove, disabled: busy === "delete" }, busy === "delete" ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664" ) @@ -4008,14 +4597,14 @@ function AccountCard3({ account, busy, removing, onReconnect, onRequestRemove, o ); } function AccountList2(props) { - return h5( + return h6( "section", { className: "dim-listSection" }, - h5("div", { className: "dxw-listHeading dim-listHeading" }, h5("h3", null, "\u5DF2\u63A5\u5165\u7684\u5FAE\u4FE1\u8D26\u53F7"), h5("span", null, `${props.bots.length} \u4E2A`)), - h5("ul", { className: "dxw-list dim-botList" }, props.bots.map((account) => h5( + 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( "li", { key: account.botId }, - h5(AccountCard3, { + h6(AccountCard4, { account, busy: props.busyByBot[account.botId], removing: props.removeTarget === account.botId, @@ -4038,38 +4627,38 @@ function mergeWeixinProvisioningSnapshot(current, incoming, { restoreProvisionin }; } function WeixinSettingsTab({ rpcCall }) { - const [model, setModel] = React6.useState({ + const [model, setModel] = React7.useState({ phase: "loading", bots: [], totals: EMPTY_TOTALS3, revision: 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 [notice, setNotice] = React6.useState(""); - const [now, setNow] = React6.useState(() => Date.now()); - const addButtonRef = React6.useRef(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 scheduleAnimationFrame = useAnimationFrameScheduler(); - const announce = React6.useCallback((value) => { + const announce = React7.useCallback((value) => { setNotice(""); scheduleAnimationFrame(() => { if (value) setNotice(value); }, "announcement"); }, [scheduleAnimationFrame]); - const invoke = React6.useCallback(async (endpoint, payload = {}, signal) => { - return unwrapRpcResult4(await rpcCall(endpoint, payload, signal)); + const invoke = React7.useCallback(async (endpoint, payload = {}, signal) => { + return unwrapRpcResult5(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React6.useCallback(async ({ + const loadStatus = React7.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { if (!silent) setModel((current) => ({ ...current, phase: "loading", error: null })); try { - const snapshot = normalizeSnapshot3(await invoke(WEIXIN_ENDPOINTS.status, {}, signal)); + const snapshot = normalizeSnapshot4(await invoke(WEIXIN_ENDPOINTS.status, {}, signal)); if (signal?.aborted) return void 0; setModel({ phase: "ready", @@ -4091,17 +4680,17 @@ function WeixinSettingsTab({ rpcCall }) { setModel((current) => ({ ...current, phase: silent && current.phase === "ready" ? "ready" : "error", - error: presentError4(error) + error: presentError5(error) })); return void 0; } }, [invoke]); - React6.useEffect(() => { + React7.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); - React6.useEffect(() => { + React7.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); let running = false; @@ -4120,33 +4709,33 @@ function WeixinSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React6.useEffect(() => { + React7.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 = React6.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React7.useCallback(async ({ replace = false } = {}) => { setBusy(true); try { if (replace && provision?.attemptId) { await invoke(WEIXIN_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); } setProvision({ status: "starting" }); - const started = normalizeProvisioning4(await invoke(WEIXIN_ENDPOINTS.beginProvisioning, { locale: "zh-CN" })); + const started = normalizeProvisioning5(await invoke(WEIXIN_ENDPOINTS.beginProvisioning, { locale: "zh-CN" })); setNow(Date.now()); setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) }); announce("\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u5DF2\u751F\u6210\uFF0C\u8BF7\u4F7F\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u3002"); } catch (error) { setProvision({ status: "failed", - error: presentError4(error), + error: presentError5(error), ...provision?.attemptId ? { attemptId: provision.attemptId } : {} }); } finally { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - const cancelProvisioning = React6.useCallback(async () => { + const cancelProvisioning = React7.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && !["failed", "expired", "cancelled"].includes(provision.status)) { @@ -4156,28 +4745,28 @@ function WeixinSettingsTab({ rpcCall }) { announce("\u5DF2\u53D6\u6D88\u5FAE\u4FE1\u7ED1\u5B9A\u3002"); scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus"); } catch (error) { - setProvision((current) => ({ ...current, status: "failed", error: presentError4(error) })); + setProvision((current) => ({ ...current, status: "failed", error: presentError5(error) })); } finally { setBusy(false); } }, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]); - const submitVerification = React6.useCallback(async (verifyCode) => { + const submitVerification = React7.useCallback(async (verifyCode) => { if (!provision?.attemptId) return; setBusy(true); try { - const next = normalizeProvisioning4(await invoke(WEIXIN_ENDPOINTS.submitVerification, { + const next = normalizeProvisioning5(await invoke(WEIXIN_ENDPOINTS.submitVerification, { attemptId: provision.attemptId, verifyCode })); setProvision((current) => ({ ...current, ...next })); announce("\u914D\u5BF9\u7801\u5DF2\u63D0\u4EA4\uFF0C\u6B63\u5728\u7B49\u5F85\u5FAE\u4FE1\u786E\u8BA4\u3002"); } catch (error) { - setProvision((current) => ({ ...current, status: "failed", error: presentError4(error) })); + setProvision((current) => ({ ...current, status: "failed", error: presentError5(error) })); } finally { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - React6.useEffect(() => { + React7.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !["pending", "scanned", "connecting"].includes(provision.status)) return void 0; const controller = new AbortController(); @@ -4187,7 +4776,7 @@ function WeixinSettingsTab({ rpcCall }) { }); const poll = async () => { try { - const result = normalizeProvisioning4(await invoke( + const result = normalizeProvisioning5(await invoke( WEIXIN_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal @@ -4216,7 +4805,7 @@ function WeixinSettingsTab({ rpcCall }) { } } catch (error) { if (scheduler.disposed || error?.name === "AbortError") return; - setProvision((current) => current?.attemptId === attemptId ? { ...current, status: "failed", error: presentError4(error) } : current); + setProvision((current) => current?.attemptId === attemptId ? { ...current, status: "failed", error: presentError5(error) } : current); } }; scheduler.schedule(poll, provision.pollIntervalMs ?? 1e3); @@ -4225,7 +4814,7 @@ function WeixinSettingsTab({ rpcCall }) { controller.abort(); }; }, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]); - const setBotBusy = React6.useCallback((botId, value) => { + const setBotBusy = React7.useCallback((botId, value) => { setBusyByBot((current) => { const next = { ...current }; if (value) next[botId] = value; @@ -4233,23 +4822,23 @@ function WeixinSettingsTab({ rpcCall }) { return next; }); }, []); - const reconnect = React6.useCallback(async (account) => { + const reconnect = React7.useCallback(async (account) => { setBotBusy(account.botId, "reconnect"); try { - const snapshot = normalizeSnapshot3(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId })); + const snapshot = normalizeSnapshot4(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId })); setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision })); const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId); announce(refreshed?.connected ? "\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002" : "\u5FAE\u4FE1\u4ECD\u672A\u8FDE\u63A5\uFF0C\u63D2\u4EF6\u4F1A\u7EE7\u7EED\u81EA\u52A8\u91CD\u8BD5\u3002"); } catch (error) { - announce(`\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF1A${presentError4(error).message}`); + announce(`\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF1A${presentError5(error).message}`); } finally { setBotBusy(account.botId, null); } }, [announce, invoke, setBotBusy]); - const remove = React6.useCallback(async (account) => { + const remove = React7.useCallback(async (account) => { setBotBusy(account.botId, "delete"); try { - const snapshot = normalizeSnapshot3(await invoke(WEIXIN_ENDPOINTS.deleteBot, { + const snapshot = normalizeSnapshot4(await invoke(WEIXIN_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true })); @@ -4257,16 +4846,16 @@ function WeixinSettingsTab({ rpcCall }) { setRemoveTarget(null); announce("\u5FAE\u4FE1\u8D26\u53F7\u53CA\u672C\u673A\u51ED\u636E\u5DF2\u79FB\u9664\u3002"); } catch (error) { - announce(`\u79FB\u9664\u5931\u8D25\uFF1A${presentError4(error).message}`); + announce(`\u79FB\u9664\u5931\u8D25\uFF1A${presentError5(error).message}`); } finally { setBotBusy(account.botId, null); } }, [announce, invoke, setBotBusy]); let provisionView = null; if (provision?.status === "starting") { - provisionView = h5(ProgressPanel2, { busy }); + provisionView = h6(ProgressPanel2, { busy }); } else if (["pending", "scanned"].includes(provision?.status)) { - provisionView = h5(QrPanel3, { + provisionView = h6(QrPanel4, { provision, now, busy, @@ -4274,54 +4863,54 @@ function WeixinSettingsTab({ rpcCall }) { onCancel: () => void cancelProvisioning() }); } else if (provision?.status === "needs_verification") { - provisionView = h5(VerificationPanel, { + provisionView = h6(VerificationPanel, { provision, busy, onSubmit: (code) => void submitVerification(code), onCancel: () => void cancelProvisioning() }); } else if (provision?.status === "connecting") { - provisionView = h5(ProgressPanel2, { + provisionView = h6(ProgressPanel2, { scanned: true, busy, onCancel: () => void cancelProvisioning() }); } else if (provision && ["failed", "expired", "cancelled"].includes(provision.status)) { - provisionView = h5(ProvisionError3, { + provisionView = h6(ProvisionError3, { provision, busy, onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }), onClose: () => void cancelProvisioning() }); } - return h5( + return h6( "section", { className: "dxw-page dim-channelPage", "aria-label": "\u5FAE\u4FE1\u8BBE\u7F6E" }, - h5(Heading4, { + h6(Heading5, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }), - h5("div", { className: "dxw-visuallyHidden", role: "status", "aria-live": "polite" }, notice), - model.error && model.phase === "ready" ? h5("div", { className: "dxw-statusNotice dim-statusNotice" }, `\u72B6\u6001\u5237\u65B0\u5931\u8D25\uFF1A${model.error.message}`) : null, - model.phase === "loading" ? h5(LoadingView4) : model.phase === "error" ? h5( + 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( "div", { className: "dxw-card dim-surfaceCard" }, - h5( + h6( "div", { className: "dxw-error dim-inlineError" }, - h5("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u5FAE\u4FE1\u72B6\u6001"), - h5("p", null, model.error?.message ?? "\u8BF7\u7A0D\u540E\u91CD\u8BD5"), - h5(Button7, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") + 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") ) - ) : h5( - React6.Fragment, + ) : h6( + React7.Fragment, null, provisionView, - model.bots.length === 0 && !provision ? h5(EmptyView4, { onStart: () => void startProvisioning(), busy }) : null, - model.bots.length > 0 ? h5(AccountList2, { + model.bots.length === 0 && !provision ? h6(EmptyView5, { onStart: () => void startProvisioning(), busy }) : null, + model.bots.length > 0 ? h6(AccountList2, { bots: model.bots, busyByBot, removeTarget, @@ -4336,7 +4925,7 @@ function WeixinSettingsTab({ rpcCall }) { // plugin-src/client/styles.js var IM_STYLE_ID = "xmanrui-dsh-im-settings"; -var CSS5 = String.raw` +var CSS6 = String.raw` .dim-page { --dim-blue: #3370ff; --dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent); @@ -4367,11 +4956,13 @@ var CSS5 = String.raw` .dim-logoDingtalk svg { width: 24px; height: 24px; } .dim-logoQq { color: white; background: #1677ff; } .dim-logoQq svg { width: 21px; height: 21px; } +.dim-logoWecom { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); } +.dim-logoWecom svg { width: 22px; height: 22px; } .dim-channelCopy { min-width: 0; display: block; } .dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; } .dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-line-divider, #eef0f3); } .dim-panel { min-width: 0; container-type: inline-size; } -.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page { width: 100%; max-width: none; padding: 0 0 24px; } +.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; } @@ -4500,69 +5091,74 @@ function installImStyles() { const style = document.createElement("style"); style.dataset.plugin = "@xmanrui/dsh-im"; style.dataset.pluginCss = IM_STYLE_ID; - style.textContent = CSS5; + style.textContent = CSS6; document.head.appendChild(style); return () => style.remove(); } // plugin-src/client/index.js -var h6 = React7.createElement; +var h7 = React8.createElement; var name = "im-settings"; var inject = ["slots", "connection"]; var CHANNELS = Object.freeze([ { id: "weixin", label: "\u5FAE\u4FE1" }, { id: "feishu", label: "\u98DE\u4E66" }, { id: "dingtalk", label: "\u9489\u9489" }, + { id: "wecom", label: "\u4F01\u4E1A\u5FAE\u4FE1" }, { id: "qq", label: "QQ" } ]); function WeixinLogo() { - return h6( + return h7( "span", { className: "dim-logo dim-logoWeixin", "aria-hidden": "true" }, - h6(WeixinLogoGlyph) + h7(WeixinLogoGlyph) ); } function FeishuLogo() { - return h6( + return h7( "span", { className: "dim-logo dim-logoFeishu", "aria-hidden": "true" }, - h6(FeishuLogoGlyph) + h7(FeishuLogoGlyph) ); } function DingtalkLogo() { - return h6( + return h7( "span", { className: "dim-logo dim-logoDingtalk", "aria-hidden": "true" }, - h6(DingtalkLogoGlyph) + h7(DingtalkLogoGlyph) ); } function QqLogo() { - return h6("span", { className: "dim-logo dim-logoQq", "aria-hidden": "true" }, h6(QqLogoGlyph)); + return h7("span", { className: "dim-logo dim-logoQq", "aria-hidden": "true" }, h7(QqLogoGlyph)); +} +function WecomLogo() { + return h7("span", { className: "dim-logo dim-logoWecom", "aria-hidden": "true" }, h7(WecomLogoGlyph)); } function ChannelLogo({ channel }) { - if (channel === "weixin") return h6(WeixinLogo); - if (channel === "feishu") return h6(FeishuLogo); - if (channel === "dingtalk") return h6(DingtalkLogo); - return h6(QqLogo); + 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); } -function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }) { - const [selected, setSelected] = React7.useState("weixin"); +function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }) { + const [selected, setSelected] = React8.useState("weixin"); const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0]; - return h6( + return h7( "section", { className: "dim-page", "aria-label": "IM\u673A\u5668\u4EBA\u8BBE\u7F6E" }, - h6( + h7( "header", { className: "dim-title" }, - h6("p", null, "\u901A\u8FC7\u626B\u7801\u628A\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness") + h7("p", null, "\u901A\u8FC7\u626B\u7801\u628A\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness") ), - h6( + h7( "div", { className: "dim-layout" }, - h6( + h7( "nav", { className: "dim-rail", role: "tablist", "aria-label": "IM \u6E20\u9053" }, - CHANNELS.map((channel) => h6( + CHANNELS.map((channel) => h7( "button", { key: channel.id, @@ -4574,27 +5170,33 @@ function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCal "aria-controls": `dim-panel-${channel.id}`, onClick: () => setSelected(channel.id) }, - h6(ChannelLogo, { channel: channel.id }), - h6( + h7(ChannelLogo, { channel: channel.id }), + h7( "span", { className: "dim-channelCopy" }, - h6("strong", null, channel.label) + h7("strong", null, channel.label) ) )) ), - h6("div", { className: "dim-divider", "aria-hidden": "true" }), - h6("main", { + h7("div", { className: "dim-divider", "aria-hidden": "true" }), + h7("main", { className: "dim-panel", role: "tabpanel", id: `dim-panel-${active.id}`, "aria-labelledby": `dim-tab-${active.id}` - }, active.id === "weixin" ? h6(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h6(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === "dingtalk" ? h6(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : h6(QqSettingsTab, { rpcCall: qqRpcCall })) + }, 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 })) ) ); } function apply(ctx) { ctx.effect(() => { - const disposers = [installFeishuStyles(), installWeixinStyles(), installQqStyles(), installImStyles()]; + const disposers = [ + installFeishuStyles(), + installWeixinStyles(), + installWecomStyles(), + installQqStyles(), + installImStyles() + ]; return () => { for (const dispose of disposers.reverse()) dispose(); }; @@ -4603,12 +5205,13 @@ function apply(ctx) { const weixinRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WEIXIN_RPC_CHANNEL, endpoint, payload, signal); const dingtalkRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(DINGTALK_RPC_CHANNEL, endpoint, payload, signal); const qqRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal); + const wecomRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal); ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({ name: "settings.plugins.tab", id: "im", order: 20, label: "IM\u673A\u5668\u4EBA", - inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }) + inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }) }, IMSettingsTab)); } diff --git a/lib/index.js b/lib/index.js index 6fd24d8..93d5ceb 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1136,14 +1136,14 @@ function abortError2(signal) { } function abortableDelay(ms, signal) { if (ms <= 0) return Promise.resolve(); - return new Promise((resolve5, reject) => { + return new Promise((resolve6, reject) => { if (signal?.aborted) { reject(abortError2(signal)); return; } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); - resolve5(); + resolve6(); }, ms); const onAbort = () => { clearTimeout(timer); @@ -1758,10 +1758,10 @@ function createDingTalkCardStream({ return false; } }; - const push = (progressText2) => { - requiredText(progressText2, "progressText"); + const push = (progressText3) => { + requiredText(progressText3, "progressText"); if (phase !== "active") return; - pendingText = progressText2; + pendingText = progressText3; if (!scheduledUpdate && !updateWorker) launchUpdate(); }; const finish = (finalText) => { @@ -2055,7 +2055,7 @@ function streamIsOpen(client) { return client?.connected === true || client?.socket?.readyState === 1; } function abortable(promise, signal) { - return new Promise((resolve5, reject) => { + return new Promise((resolve6, reject) => { if (signal.aborted) { reject(signal.reason); return; @@ -2065,7 +2065,7 @@ function abortable(promise, signal) { Promise.resolve(promise).then( (value) => { signal.removeEventListener("abort", onAbort); - resolve5(value); + resolve6(value); }, (error) => { signal.removeEventListener("abort", onAbort); @@ -2078,10 +2078,10 @@ async function waitForStreamOpen(client, pollIntervalMs, signal) { while (true) { signal?.throwIfAborted(); if (streamIsOpen(client)) return; - await new Promise((resolve5, reject) => { + await new Promise((resolve6, reject) => { const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); - resolve5(); + resolve6(); }, pollIntervalMs); const onAbort = () => { clearTimeout(timer); @@ -2356,14 +2356,14 @@ var DingtalkRuntime = class { import { spawn } from "node:child_process"; import { randomUUID as randomUUID4 } from "node:crypto"; function sleep(ms, signal) { - return new Promise((resolve5, reject) => { + return new Promise((resolve6, reject) => { if (signal?.aborted) { reject(signal.reason ?? new DOMException("Aborted", "AbortError")); return; } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); - resolve5(); + resolve6(); }, ms); const onAbort = () => { clearTimeout(timer); @@ -2834,8 +2834,8 @@ var ConnectionSupervisor = class { this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve5) => { - this.#resolveReady = resolve5; + this.#ready = new Promise((resolve6) => { + this.#resolveReady = resolve6; }); } get ready() { @@ -3410,8 +3410,8 @@ var ConnectionSupervisor2 = class { this.#healthyIntervalMs = safeDelay(healthyIntervalMs, 15e3); this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve5) => { - this.#resolveReady = resolve5; + this.#ready = new Promise((resolve6) => { + this.#resolveReady = resolve6; }); } get ready() { @@ -4045,7 +4045,7 @@ var FeishuRuntime = class { }); let settleReady; let settleError; - const ready = new Promise((resolve5, reject) => { + const ready = new Promise((resolve6, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) return; @@ -4056,7 +4056,7 @@ var FeishuRuntime = class { if (settled) return; settled = true; clearTimeout(timer); - resolve5(); + resolve6(); }; settleError = (error) => { if (settled) return; @@ -4126,7 +4126,7 @@ var FeishuRuntime = class { // src/channels/feishu/harness-client.mjs import { spawn as spawn2 } from "node:child_process"; import { randomUUID as randomUUID6 } from "node:crypto"; -var sleep2 = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)); +var sleep2 = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms)); function messageText(event) { return (event?.data?.message?.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim(); } @@ -5810,7 +5810,7 @@ function validPayload(endpoint, payload) { return "Unknown Feishu endpoint."; } function abortableDelay2(milliseconds, signal) { - return new Promise((resolve5, reject) => { + return new Promise((resolve6, reject) => { if (signal?.aborted) { reject(signal.reason ?? new Error("aborted")); return; @@ -5819,7 +5819,7 @@ function abortableDelay2(milliseconds, signal) { timer.unref?.(); function done() { signal?.removeEventListener("abort", aborted); - resolve5(); + resolve6(); } function aborted() { clearTimeout(timer); @@ -5881,8 +5881,8 @@ function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl } = {}) { return async (endpoint, payload, signal) => { if (signal?.aborted) return cancelled2(); if (!FEISHU_RPC_ENDPOINTS.includes(endpoint)) return badRequest2("Unknown Feishu endpoint."); - const payloadFailure4 = validPayload(endpoint, payload); - if (payloadFailure4) return badRequest2(payloadFailure4); + const payloadFailure5 = validPayload(endpoint, payload); + if (payloadFailure5) return badRequest2(payloadFailure5); try { let value; if (endpoint === FEISHU_ENDPOINTS.status) { @@ -6147,7 +6147,7 @@ var QqConfigStore = class { // src/channels/weixin/harness-client.mjs import { spawn as spawn3 } from "node:child_process"; import { randomUUID as randomUUID8 } from "node:crypto"; -var sleep3 = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)); +var sleep3 = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms)); function assistantMessageText2(event) { return (event?.data?.message?.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim(); } @@ -6461,8 +6461,8 @@ var QqController = class { if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); let firstQrResolve; let firstQrReject; - const firstQr = new Promise((resolve5, reject) => { - firstQrResolve = resolve5; + const firstQr = new Promise((resolve6, reject) => { + firstQrResolve = resolve6; firstQrReject = reject; }); const record = { @@ -7018,8 +7018,8 @@ var QqRuntime = class { this.#abortController = controller; let readyResolve; let readyReject; - const ready = new Promise((resolve5, reject) => { - readyResolve = resolve5; + const ready = new Promise((resolve6, reject) => { + readyResolve = resolve6; readyReject = reject; }); const onReady = () => { @@ -7092,7 +7092,7 @@ var QqRuntime = class { } await Promise.race([ runTask?.catch(() => void 0) ?? Promise.resolve(), - new Promise((resolve5) => setTimeout(resolve5, 2e3)) + new Promise((resolve6) => setTimeout(resolve6, 2e3)) ]); await bridge?.waitForIdle(); this.#status.ready = false; @@ -7251,8 +7251,8 @@ var ConnectionSupervisor3 = class { this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve5) => { - this.#resolveReady = resolve5; + this.#ready = new Promise((resolve6) => { + this.#resolveReady = resolve6; }); } get ready() { @@ -7546,18 +7546,1509 @@ async function apply3(ctx, config = {}) { return disposeRpc; } -// plugin-src/host/channels/weixin/production.mjs +// plugin-src/host/channels/wecom/production.mjs import { unlink as unlink11 } from "node:fs/promises"; import { homedir as homedir4 } from "node:os"; import { join as join4, resolve as resolve4 } from "node:path"; -// src/channels/weixin/config-store.mjs +// src/channels/wecom/config-store.mjs import { createHash as createHash4 } from "node:crypto"; import { mkdir as mkdir7, readFile as readFile7, rename as rename7, unlink as unlink9, writeFile as writeFile7 } from "node:fs/promises"; import { dirname as dirname7 } from "node:path"; +var EMPTY_DOCUMENT3 = Object.freeze({ version: 1, bots: Object.freeze([]) }); +function cleanString7(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function safeIntegrationId(value) { + const id = cleanString7(value); + return id && /^wecom_[a-f0-9]{24}$/.test(id) ? id : null; +} +function safeSecretRef3(value) { + const ref = cleanString7(value); + return ref && /^DSH_WECOM_BOT_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null; +} +function deriveWecomBotIdentity(remoteBotId) { + const raw = cleanString7(remoteBotId); + if (!raw) throw new TypeError("Enterprise WeChat bot ID is required"); + const digest2 = createHash4("sha256").update(raw).digest("hex").slice(0, 24); + return { + botId: `wecom_${digest2}`, + secretRef: `DSH_WECOM_BOT_SECRET_${digest2.toUpperCase()}` + }; +} +function maskWecomBotId(remoteBotId) { + const value = cleanString7(remoteBotId) ?? ""; + if (!value) return "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA"; + if (value.length <= 10) return `${value.slice(0, 3)}\u2022\u2022\u2022`; + return `${value.slice(0, 6)}\u2022\u2022\u2022\u2022${value.slice(-4)}`; +} +function normalizeBot4(value) { + if (!value || typeof value !== "object") return null; + const botId = safeIntegrationId(value.botId); + const remoteBotId = cleanString7(value.remoteBotId); + const secretRef = safeSecretRef3(value.secretRef); + if (!botId || !remoteBotId || !secretRef) return null; + const derived = deriveWecomBotIdentity(remoteBotId); + if (derived.botId !== botId || derived.secretRef !== secretRef) return null; + return Object.freeze({ + botId, + remoteBotId, + secretRef, + createdAt: cleanString7(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: cleanString7(value.connectedAt) + }); +} +function normalizeDocument4(value) { + if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null; + const bots = value.bots.map(normalizeBot4); + if (bots.some((bot) => bot === null)) return null; + const ids = /* @__PURE__ */ new Set(); + const remoteIds = /* @__PURE__ */ new Set(); + const refs = /* @__PURE__ */ new Set(); + for (const bot of bots) { + if (ids.has(bot.botId) || remoteIds.has(bot.remoteBotId) || refs.has(bot.secretRef)) return null; + ids.add(bot.botId); + remoteIds.add(bot.remoteBotId); + refs.add(bot.secretRef); + } + return Object.freeze({ version: 1, bots: Object.freeze(bots) }); +} +var WecomConfigStore = class { + #path; + #value = EMPTY_DOCUMENT3; + #writeQueue = Promise.resolve(); + constructor(path) { + this.#path = path; + } + async load() { + try { + const normalized = normalizeDocument4(JSON.parse(await readFile7(this.#path, "utf8"))); + if (!normalized) throw new Error("dsh-im Enterprise WeChat config contains invalid bot data"); + this.#value = normalized; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + this.#value = EMPTY_DOCUMENT3; + } + return this; + } + list() { + return structuredClone(this.#value.bots); + } + get(botId) { + const bot = this.#value.bots.find((candidate) => candidate.botId === botId); + return bot ? structuredClone(bot) : null; + } + getByRemoteBotId(remoteBotId) { + const bot = this.#value.bots.find((candidate) => candidate.remoteBotId === remoteBotId); + return bot ? structuredClone(bot) : null; + } + async save(value) { + const normalized = normalizeBot4(value); + if (!normalized) throw new Error("Refusing to persist incomplete Enterprise WeChat bot data"); + return this.#mutate((bots) => { + const remoteCollision = bots.find( + (bot) => bot.remoteBotId === normalized.remoteBotId && bot.botId !== normalized.botId + ); + const refCollision = bots.find( + (bot) => bot.secretRef === normalized.secretRef && bot.botId !== normalized.botId + ); + if (remoteCollision || refCollision) throw new Error("Duplicate Enterprise WeChat bot identity"); + const index = bots.findIndex((bot) => bot.botId === normalized.botId); + if (index === -1) bots.push(normalized); + else bots[index] = normalized; + return structuredClone(normalized); + }); + } + async remove(botId) { + if (!safeIntegrationId(botId)) throw new TypeError("Invalid Enterprise WeChat bot ID"); + return this.#mutate((bots) => { + const index = bots.findIndex((bot) => bot.botId === botId); + if (index === -1) return null; + const [removed] = bots.splice(index, 1); + return structuredClone(removed); + }); + } + async clear() { + const operation = this.#writeQueue.then(async () => { + try { + await unlink9(this.#path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + this.#value = EMPTY_DOCUMENT3; + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + } + async #mutate(mutator) { + let result; + const operation = this.#writeQueue.then(async () => { + const bots = [...this.#value.bots]; + result = mutator(bots); + const document = Object.freeze({ version: 1, bots: Object.freeze(bots) }); + await this.#write(document); + this.#value = document; + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + return result; + } + async #write(document) { + await mkdir7(dirname7(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.tmp`; + await writeFile7(temporary, `${JSON.stringify(document, null, 2)} +`, { + encoding: "utf8", + mode: 384 + }); + await rename7(temporary, this.#path); + } +}; + +// src/channels/wecom/harness-client.mjs +var WecomHarnessClient = class extends HarnessClient3 { +}; + +// src/channels/wecom/qr-auth.mjs +var GENERATE_URL = "https://work.weixin.qq.com/ai/qc/generate"; +var POLL_URL = "https://work.weixin.qq.com/ai/qc/query_result"; +var QR_TTL_MS2 = 5 * 6e4; +var POLL_INTERVAL_MS = 3e3; +function defaultPlatform() { + if (process.platform === "win32") return 2; + if (process.platform === "linux") return 3; + return 1; +} +function cleanString8(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function safeVerificationUrl(value) { + const raw = cleanString8(value); + if (!raw) return null; + try { + const url = new URL(raw); + return url.protocol === "https:" && url.hostname === "work.weixin.qq.com" && (!url.port || url.port === "443") ? url.href : null; + } catch { + return null; + } +} +function combinedSignal(signal, timeoutMs) { + return signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs); +} +async function requestJson2(fetchImpl, url, signal) { + const response = await fetchImpl(url, { + method: "GET", + redirect: "error", + signal: combinedSignal(signal, 1e4), + headers: { accept: "application/json" } + }); + if (!response.ok) throw new Error(`Enterprise WeChat QR service returned HTTP ${response.status}`); + return response.json(); +} +var WecomQrAuth = class { + #fetch; + #clock; + #source; + #platform; + constructor({ + fetch: fetchImpl = globalThis.fetch, + clock = () => Date.now(), + source = "deepseek-harness", + platform = defaultPlatform() + } = {}) { + if (typeof fetchImpl !== "function") throw new TypeError("A fetch implementation is required"); + this.#fetch = fetchImpl; + this.#clock = clock; + this.#source = source; + this.#platform = [1, 2, 3].includes(platform) ? platform : defaultPlatform(); + } + async start({ signal } = {}) { + const url = new URL(GENERATE_URL); + url.searchParams.set("source", this.#source); + url.searchParams.set("plat", String(this.#platform)); + const body = await requestJson2(this.#fetch, url, signal); + const scode = cleanString8(body?.data?.scode); + const verificationUrl = safeVerificationUrl(body?.data?.auth_url); + if (!scode || !verificationUrl) throw new Error("Enterprise WeChat QR service returned invalid data"); + return { + scode, + verificationUrl, + expiresAt: this.#clock() + QR_TTL_MS2, + pollIntervalMs: POLL_INTERVAL_MS + }; + } + async poll({ scode, signal } = {}) { + const code = cleanString8(scode); + if (!code) throw new TypeError("Enterprise WeChat QR poll code is required"); + const url = new URL(POLL_URL); + url.searchParams.set("scode", code); + const body = await requestJson2(this.#fetch, url, signal); + const state = cleanString8(body?.data?.status)?.toLowerCase(); + if (state === "success") { + const remoteBotId = cleanString8(body?.data?.bot_info?.botid); + const secret = cleanString8(body?.data?.bot_info?.secret); + if (!remoteBotId || !secret) throw new Error("Enterprise WeChat QR result omitted bot credentials"); + return { status: "success", remoteBotId, secret }; + } + if (["expired", "timeout"].includes(state)) return { status: "expired" }; + if (["fail", "failed", "error"].includes(state)) return { status: "failed" }; + return { status: "waiting" }; + } +}; + +// src/channels/wecom/state-store.mjs +import { mkdir as mkdir8, readFile as readFile8, rename as rename8, unlink as unlink10, writeFile as writeFile8 } from "node:fs/promises"; +import { dirname as dirname8 } from "node:path"; +var EMPTY_STATE4 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); +function normalizeState3(value) { + if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE4); + const sessions = {}; + if (value.sessions && typeof value.sessions === "object" && !Array.isArray(value.sessions)) { + for (const [key, sessionId] of Object.entries(value.sessions)) { + if (typeof key === "string" && typeof sessionId === "string" && sessionId) sessions[key] = sessionId; + } + } + return { + version: 1, + sessions, + seenMessageIds: Array.isArray(value.seenMessageIds) ? value.seenMessageIds.filter((id) => typeof id === "string").slice(-1e3) : [] + }; +} +var WecomStateStore = class { + #path; + #state = structuredClone(EMPTY_STATE4); + #writeQueue = Promise.resolve(); + constructor(path) { + this.#path = path; + } + async load() { + try { + this.#state = normalizeState3(JSON.parse(await readFile8(this.#path, "utf8"))); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + this.#state = structuredClone(EMPTY_STATE4); + await this.#persist(); + } + return this; + } + sessionFor(key) { + return this.#state.sessions[key] ?? null; + } + async setSession(key, sessionId) { + this.#state.sessions[key] = sessionId; + await this.#persist(); + } + async clearSession(key) { + delete this.#state.sessions[key]; + await this.#persist(); + } + hasSeen(messageId) { + return this.#state.seenMessageIds.includes(messageId); + } + async markSeen(messageId) { + if (this.hasSeen(messageId)) return; + this.#state.seenMessageIds.push(messageId); + if (this.#state.seenMessageIds.length > 1e3) { + this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1e3); + } + await this.#persist(); + } + async remove() { + try { + await unlink10(this.#path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + this.#state = structuredClone(EMPTY_STATE4); + } + async #persist() { + const snapshot = `${JSON.stringify(this.#state, null, 2)} +`; + const operation = this.#writeQueue.then(async () => { + await mkdir8(dirname8(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.tmp`; + await writeFile8(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename8(temporary, this.#path); + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + } +}; + +// src/channels/wecom/wecom-controller.mjs +import { randomUUID as randomUUID10 } from "node:crypto"; +var ACTIVE_ATTEMPT_STATES3 = /* @__PURE__ */ new Set(["pending", "connecting"]); +var TERMINAL_ATTEMPT_STATES3 = /* @__PURE__ */ new Set(["connected", "failed", "cancelled", "expired"]); +function cleanString9(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function safeError3(code, message) { + return Object.freeze({ code, message }); +} +function publicAttempt3(record) { + if (!record) return null; + return { + attemptId: record.id, + status: record.state, + pollIntervalMs: record.pollIntervalMs, + qrRevision: record.qrRevision, + ...record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}, + ...record.expiresAt ? { expiresAt: record.expiresAt } : {}, + ...record.botId ? { botId: record.botId } : {}, + ...record.error ? { error: structuredClone(record.error) } : {} + }; +} +var WecomController = class { + #qrAuth; + #credentials; + #configStore; + #createRuntime; + #deleteState; + #logger; + #runtimes = /* @__PURE__ */ new Map(); + #errors = /* @__PURE__ */ new Map(); + #attempts = /* @__PURE__ */ new Map(); + #activeAttemptId = null; + #transitions = /* @__PURE__ */ new Map(); + #revision = 0; + #closed = false; + constructor({ + qrAuth, + credentials, + configStore, + createRuntime, + deleteState = async () => { + }, + logger = console + }) { + if (!qrAuth || typeof qrAuth.start !== "function" || typeof qrAuth.poll !== "function") { + throw new TypeError("Enterprise WeChat QR auth is required"); + } + if (!credentials || typeof credentials.resolve !== "function" || typeof credentials.set !== "function" || typeof credentials.unset !== "function") { + throw new TypeError("WecomController requires the DSH credential provider"); + } + if (!configStore || typeof configStore.list !== "function" || typeof configStore.save !== "function" || typeof configStore.remove !== "function") { + throw new TypeError("WecomController requires a config store"); + } + if (typeof createRuntime !== "function") throw new TypeError("createRuntime is required"); + this.#qrAuth = qrAuth; + this.#credentials = credentials; + this.#configStore = configStore; + this.#createRuntime = createRuntime; + this.#deleteState = deleteState; + this.#logger = logger; + } + async initialize() { + if (this.#closed) return this.status(); + for (const config of this.#configStore.list()) { + await this.#withBotTransition(config.botId, async () => { + const existing = this.#runtimes.get(config.botId)?.status; + if (this.#closed || existing?.ready || existing?.wecomConnectionState === "connecting") return; + const secret = await this.#resolveSecret(config.secretRef); + if (!secret) { + this.#errors.set(config.botId, safeError3("missing-secret", "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u51ED\u636E\u7F3A\u5931\uFF0C\u8BF7\u79FB\u9664\u540E\u91CD\u65B0\u626B\u7801\u3002")); + return; + } + try { + await this.#startRuntime(config, secret); + this.#errors.delete(config.botId); + } catch (error) { + this.#errors.set(config.botId, safeError3("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5\u3002")); + this.#logger.warn?.(`[dsh-im:wecom] bot ${config.botId} failed to initialize`); + } finally { + this.#touch(); + } + }); + } + return this.status(); + } + async startProvisioning() { + if (this.#closed) throw new Error("Enterprise WeChat controller is closed"); + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + const record = { + id: randomUUID10(), + state: "pending", + createdAt: Date.now(), + expiresAt: null, + pollIntervalMs: 3e3, + qrRevision: 1, + verificationUrl: null, + scode: null, + botId: null, + error: null, + controller: new AbortController(), + polling: null, + transition: null + }; + this.#attempts.set(record.id, record); + this.#activeAttemptId = record.id; + this.#touch(); + try { + const started = await this.#qrAuth.start({ signal: record.controller.signal }); + record.scode = cleanString9(started.scode); + record.verificationUrl = cleanString9(started.verificationUrl); + record.expiresAt = Number(started.expiresAt); + record.pollIntervalMs = Math.min(1e4, Math.max(500, Number(started.pollIntervalMs) || 3e3)); + if (!record.scode || !record.verificationUrl || !Number.isFinite(record.expiresAt)) { + throw new Error("Enterprise WeChat QR auth returned incomplete data"); + } + this.#touch(); + return publicAttempt3(record); + } catch (error) { + record.state = record.controller.signal.aborted ? "cancelled" : "failed"; + record.error = record.controller.signal.aborted ? safeError3("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002") : safeError3("qr-start-failed", "\u65E0\u6CD5\u751F\u6210\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"); + this.#finishAttempt(record); + throw error; + } + } + async registrationStatus(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record || TERMINAL_ATTEMPT_STATES3.has(record.state)) return publicAttempt3(record); + if (record.state === "connecting") { + await record.transition?.catch(() => void 0); + return publicAttempt3(record); + } + if (Date.now() >= record.expiresAt) { + record.state = "expired"; + record.error = safeError3("expired", "\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); + record.controller.abort(); + this.#finishAttempt(record); + return publicAttempt3(record); + } + if (!record.polling) { + const polling = this.#pollAttempt(record).finally(() => { + if (record.polling === polling) record.polling = null; + }); + record.polling = polling; + } + await record.polling.catch(() => void 0); + return publicAttempt3(record); + } + async cancelProvisioning(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record) return null; + if (!TERMINAL_ATTEMPT_STATES3.has(record.state)) { + record.controller.abort(); + await Promise.allSettled([record.polling, record.transition].filter(Boolean)); + if (!TERMINAL_ATTEMPT_STATES3.has(record.state)) record.state = "cancelled"; + record.error ??= safeError3("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); + this.#finishAttempt(record); + } + return publicAttempt3(record); + } + async reconnectBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error("Unknown Enterprise WeChat bot"); + await this.#withBotTransition(botId, async () => { + const secret = await this.#resolveSecret(config.secretRef); + if (!secret) throw new Error("Enterprise WeChat bot secret is missing"); + try { + await this.#startRuntime(config, secret); + this.#errors.delete(botId); + } catch (error) { + this.#errors.set(botId, safeError3("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u4ECD\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002")); + throw error; + } finally { + this.#touch(); + } + }); + return this.status(); + } + async deleteBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error("Unknown Enterprise WeChat bot"); + await this.#withBotTransition(botId, async () => { + const previous = await this.#credentials.resolve(config.secretRef).catch(() => void 0); + await this.#stopRuntime(botId); + try { + await this.#credentials.unset(config.secretRef); + await this.#configStore.remove(botId); + } catch (error) { + if (previous?.value) { + await this.#credentials.set(config.secretRef, previous.value).catch(() => void 0); + await this.#startRuntime(config, previous.value).catch(() => void 0); + } + throw new Error("Unable to remove the Enterprise WeChat bot safely.", { cause: error }); + } + await this.#deleteState({ botId, config }).catch((error) => { + this.#logger.warn?.(`[dsh-im:wecom] bot ${botId} state cleanup failed:`, error); + }); + this.#errors.delete(botId); + this.#touch(); + }); + return this.status(); + } + status() { + const bots = this.#configStore.list().map((config) => { + const runtimeStatus2 = this.#runtimes.get(config.botId)?.status ?? null; + const connected = runtimeStatus2?.ready === true && runtimeStatus2.wecomConnectionState === "connected" && runtimeStatus2.harnessReachable === true; + const state = connected ? "connected" : runtimeStatus2?.wecomConnectionState === "connecting" ? "connecting" : this.#errors.has(config.botId) || runtimeStatus2?.wecomConnectionState === "failed" ? "error" : "offline"; + return { + botId: config.botId, + state, + connected, + configured: true, + bot: { name: "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA", appIdMasked: maskWecomBotId(config.remoteBotId) }, + health: { + status: connected ? "healthy" : state === "error" ? "error" : "offline", + summary: connected ? "\u4F01\u4E1A\u5FAE\u4FE1 WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : state === "error" ? "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5" : "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5F53\u524D\u79BB\u7EBF", + lastCheckedAt: runtimeStatus2?.lastCheckedAt ?? null, + lastConnectedAt: runtimeStatus2?.lastConnectedAt ?? null + }, + stats: { + messagesReceived: runtimeStatus2?.messagesReceived ?? 0, + messagesReplied: runtimeStatus2?.messagesReplied ?? 0 + }, + error: structuredClone(this.#errors.get(config.botId) ?? null) + }; + }); + const connectedCount = bots.filter((bot) => bot.connected).length; + const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null; + return { + schemaVersion: 1, + revision: this.#revision, + state: active && ACTIVE_ATTEMPT_STATES3.has(active.state) ? "provisioning" : bots.length === 0 ? "disconnected" : connectedCount === bots.length ? "connected" : connectedCount > 0 ? "degraded" : "offline", + bots, + totals: { configured: bots.length, connected: connectedCount }, + ...active && ACTIVE_ATTEMPT_STATES3.has(active.state) ? { provisioning: publicAttempt3(active) } : {} + }; + } + async close() { + if (this.#closed) return; + this.#closed = true; + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); + } + async #pollAttempt(record) { + try { + const result = await this.#qrAuth.poll({ scode: record.scode, signal: record.controller.signal }); + if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES3.has(record.state)) return; + if (result.status === "waiting") return; + if (result.status === "expired") { + record.state = "expired"; + record.error = safeError3("expired", "\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); + this.#finishAttempt(record); + return; + } + if (result.status !== "success") { + record.state = "failed"; + record.error = safeError3("qr-connect-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u626B\u7801\u6CA1\u6709\u5B8C\u6210\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u3002"); + this.#finishAttempt(record); + return; + } + record.state = "connecting"; + record.verificationUrl = null; + record.expiresAt = null; + record.scode = null; + this.#touch(); + const transition = this.#completeProvisioning(record, result); + record.transition = transition; + await transition; + } catch (error) { + if (record.controller.signal.aborted) return; + record.state = "failed"; + record.error = safeError3("qr-connect-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u626B\u7801\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u3002"); + this.#logger.warn?.("[dsh-im:wecom] QR polling failed"); + this.#finishAttempt(record); + } + } + async #completeProvisioning(record, result) { + try { + const remoteBotId = cleanString9(result.remoteBotId); + const secret = cleanString9(result.secret); + if (!remoteBotId || !secret) throw new Error("Enterprise WeChat authorization returned incomplete credentials"); + record.botId = await this.#activateBot(record, { remoteBotId, secret }); + record.state = "connected"; + record.error = null; + } catch (error) { + if (record.controller.signal.aborted) { + record.state = "cancelled"; + record.error = safeError3("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); + } else { + record.state = "failed"; + record.error = safeError3("activation-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u5B89\u5168\u4FDD\u5B58\u63A5\u5165\u914D\u7F6E\u3002"); + this.#logger.error?.("[dsh-im:wecom] provisioning failed"); + } + } finally { + this.#finishAttempt(record); + } + } + async #activateBot(record, { remoteBotId, secret }) { + const identity = deriveWecomBotIdentity(remoteBotId); + const previousConfig = this.#configStore.getByRemoteBotId(remoteBotId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0); + const config = { + botId: identity.botId, + remoteBotId, + secretRef: identity.secretRef, + createdAt: previousConfig?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: (/* @__PURE__ */ new Date()).toISOString() + }; + return this.#withBotTransition(identity.botId, async () => { + await this.#credentials.set(identity.secretRef, secret); + try { + if (record.controller.signal.aborted) throw new DOMException("Cancelled", "AbortError"); + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + try { + if (record.controller.signal.aborted) throw new DOMException("Cancelled", "AbortError"); + await this.#startRuntime(config, secret); + this.#errors.delete(identity.botId); + } catch (error) { + if (record.controller.signal.aborted) { + await this.#stopRuntime(identity.botId); + if (previousConfig) await this.#configStore.save(previousConfig).catch(() => void 0); + else await this.#configStore.remove(identity.botId).catch(() => void 0); + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + 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} activation connection failed`); + } + this.#touch(); + return identity.botId; + }); + } + async #startRuntime(config, secret) { + await this.#stopRuntime(config.botId); + 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"); + } + this.#runtimes.set(config.botId, runtime); + try { + await runtime.start(); + } catch (error) { + await runtime.stop().catch(() => void 0); + this.#runtimes.delete(config.botId); + throw error; + } + } + async #stopRuntime(botId) { + const runtime = this.#runtimes.get(botId); + this.#runtimes.delete(botId); + await runtime?.stop().catch((error) => { + this.#logger.warn?.(`[dsh-im:wecom] bot ${botId} failed to stop cleanly:`, error); + }); + } + async #resolveSecret(ref) { + const result = await this.#credentials.resolve(ref).catch(() => void 0); + return cleanString9(result?.value); + } + async #restoreCredential(ref, previous) { + if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => void 0); + else await this.#credentials.unset(ref).catch(() => void 0); + } + #withBotTransition(botId, operation) { + const previous = this.#transitions.get(botId) ?? Promise.resolve(); + const current = previous.catch(() => void 0).then(operation); + const settled = current.finally(() => { + if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId); + }); + this.#transitions.set(botId, settled); + return settled; + } + #finishAttempt(record) { + record.scode = null; + record.verificationUrl = null; + record.expiresAt = null; + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + const terminal = [...this.#attempts.values()].filter((attempt) => TERMINAL_ATTEMPT_STATES3.has(attempt.state)); + while (terminal.length > 16) this.#attempts.delete(terminal.shift().id); + } + #touch() { + this.#revision += 1; + } +}; + +// src/channels/wecom/wecom-runtime.mjs +import { WSAuthFailureError, WSClient, WSReconnectExhaustedError } from "@wecom/aibot-node-sdk"; + +// src/channels/wecom/wecom-bridge.mjs +import { generateReqId } from "@wecom/aibot-node-sdk"; +var HELP_TEXT4 = [ + "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u5DF2\u8FDE\u63A5 DeepSeek Harness\u3002", + "", + "\u76F4\u63A5\u53D1\u9001\u6587\u5B57\u5373\u53EF\u7EE7\u7EED\u5F53\u524D\u4F1A\u8BDD\u3002", + "/new \u5F00\u542F\u4E00\u4E2A\u5168\u65B0\u4F1A\u8BDD", + "/status \u68C0\u67E5\u8FDE\u63A5\u72B6\u6001", + "/help \u663E\u793A\u672C\u5E2E\u52A9" +].join("\n"); +var MAX_REPLY_BYTES = 18e3; +function bodyOf(frame) { + return frame?.body && typeof frame.body === "object" ? frame.body : {}; +} +function conversationKey4(frame) { + const body = bodyOf(frame); + return body.chattype === "group" ? `group:${body.chatid}` : `direct:${body.from?.userid}`; +} +function messageText2(frame) { + const body = bodyOf(frame); + if (body.msgtype === "text") return typeof body.text?.content === "string" ? body.text.content.trim() : ""; + if (body.msgtype === "voice") return typeof body.voice?.content === "string" ? body.voice.content.trim() : ""; + if (body.msgtype === "mixed" && Array.isArray(body.mixed?.msg_item)) { + return body.mixed.msg_item.filter((item) => item?.msgtype === "text" && typeof item.text?.content === "string").map((item) => item.text.content).join("\n").trim(); + } + return ""; +} +function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) { + const source = String(text ?? "").trim(); + if (!source) return []; + const chunks = []; + let current = ""; + let bytes = 0; + for (const character of source) { + const size = Buffer.byteLength(character); + if (current && bytes + size > maxBytes) { + chunks.push(current); + current = character; + bytes = size; + } else { + current += character; + bytes += size; + } + } + if (current) chunks.push(current); + return chunks; +} +function progressText2(update) { + if (update?.type === "text") return update.text; + if (update?.type === "tool") return `\u6B63\u5728\u4F7F\u7528${update.name}\u2026`; + return update?.text; +} +function createWecomBridgeStatus() { + return { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null + }; +} +var WecomHarnessBridge = class { + #client; + #harness; + #state; + #status; + #logger; + #replyTimeoutMs; + #generateReqId; + #queues = /* @__PURE__ */ new Map(); + constructor({ + client, + harness, + state, + status = createWecomBridgeStatus(), + logger = console, + replyTimeoutMs = 6e5, + generateStreamId = generateReqId + }) { + if (!client || typeof client.replyStream !== "function" || typeof client.sendMessage !== "function") { + throw new TypeError("Enterprise WeChat client is required"); + } + if (!harness || !state) throw new TypeError("Harness client and state store are required"); + this.#client = client; + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#generateReqId = generateStreamId; + } + get status() { + return structuredClone(this.#status); + } + accept(frame) { + const key = conversationKey4(frame); + const previous = this.#queues.get(key) ?? Promise.resolve(); + const current = previous.catch(() => void 0).then(() => this.#process(frame)).finally(() => { + if (this.#queues.get(key) === current) this.#queues.delete(key); + }); + this.#queues.set(key, current); + return current; + } + async waitForIdle() { + await Promise.allSettled([...this.#queues.values()]); + } + async #sendActive(chatId, text) { + for (const chunk of splitUtf8(text)) { + await this.#client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: chunk } }); + } + } + async #sendImmediate(frame, chatId, text) { + const chunks = splitUtf8(text); + if (chunks.length === 0) return; + try { + await this.#client.replyStream(frame, this.#generateReqId("stream"), chunks[0], true); + for (const chunk of chunks.slice(1)) { + await this.#client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: chunk } }); + } + } catch { + await this.#sendActive(chatId, text); + } + } + async #process(frame) { + const body = bodyOf(frame); + const messageId = typeof body.msgid === "string" ? body.msgid : ""; + const senderId = typeof body.from?.userid === "string" ? body.from.userid : ""; + const chatId = body.chattype === "group" ? body.chatid : senderId; + if (!messageId || !senderId || !chatId || !["single", "group"].includes(body.chattype)) return; + if (this.#state.hasSeen(messageId)) return; + this.#status.messagesReceived += 1; + this.#status.lastMessageAt = (/* @__PURE__ */ new Date()).toISOString(); + const text = messageText2(frame); + let streamId = null; + let streamStarted = false; + try { + if (!text) { + await this.#sendImmediate(frame, chatId, "\u76EE\u524D\u652F\u6301\u6587\u5B57\u3001\u8BED\u97F3\u8F6C\u5199\u548C\u56FE\u6587\u6DF7\u6392\u4E2D\u7684\u6587\u5B57\u6D88\u606F\u3002"); + await this.#state.markSeen(messageId); + return; + } + const command = text.toLowerCase(); + if (command === "/help") { + await this.#sendImmediate(frame, chatId, HELP_TEXT4); + await this.#state.markSeen(messageId); + return; + } + if (command === "/status") { + await this.#harness.ensureRunning(); + await this.#sendImmediate(frame, chatId, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u4E0E DeepSeek Harness \u8FDE\u63A5\u6B63\u5E38\u3002"); + await this.#state.markSeen(messageId); + return; + } + const key = conversationKey4(frame); + if (command === "/new") { + await this.#state.clearSession(key); + await this.#sendImmediate(frame, chatId, "\u5DF2\u5F00\u542F\u65B0\u4F1A\u8BDD\u3002\u8BF7\u53D1\u9001\u4F60\u7684\u95EE\u9898\u3002"); + await this.#state.markSeen(messageId); + return; + } + let sessionId = this.#state.sessionFor(key); + if (!sessionId || !await this.#harness.sessionExists(sessionId)) { + sessionId = await this.#harness.createSession(); + await this.#state.setSession(key, sessionId); + } + streamId = this.#generateReqId("stream"); + try { + await this.#client.replyStream(frame, streamId, "\u6B63\u5728\u601D\u8003\u4E2D\u2026", false); + streamStarted = true; + } catch (error) { + this.#logger.warn?.("[dsh-im:wecom] unable to start a stream; using an active reply:", error); + } + const answer = await this.#harness.ask(sessionId, text, { + timeoutMs: this.#replyTimeoutMs, + onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === "function" ? async (update) => { + const progress = splitUtf8(progressText2(update))[0]; + if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false); + } : void 0 + }); + const chunks = splitUtf8(answer || "\u4EFB\u52A1\u5DF2\u5B8C\u6210\uFF0C\u4F46\u6CA1\u6709\u751F\u6210\u53EF\u663E\u793A\u7684\u6587\u672C\u3002"); + let finalSent = false; + if (streamStarted && chunks.length > 0) { + try { + await this.#client.replyStream(frame, streamId, chunks[0], true); + for (const chunk of chunks.slice(1)) { + await this.#client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: chunk } }); + } + finalSent = true; + } catch (error) { + this.#logger.warn?.("[dsh-im:wecom] stream finalization failed; using an active reply:", error); + } + } + if (!finalSent) await this.#sendActive(chatId, answer); + await this.#state.markSeen(messageId); + this.#status.messagesReplied += 1; + this.#status.lastReplyAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.lastError = null; + } catch (error) { + this.#status.lastError = error?.message ?? String(error); + this.#logger.error?.("[dsh-im:wecom] failed to process an inbound message"); + try { + if (streamStarted && streamId) { + await this.#client.replyStream(frame, streamId, "\u6D88\u606F\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002", true); + } else { + await this.#sendImmediate(frame, chatId, "\u6D88\u606F\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"); + } + await this.#state.markSeen(messageId); + } catch { + this.#logger.error?.("[dsh-im:wecom] failed to send the safe error reply"); + } + } + } +}; + +// src/channels/wecom/wecom-runtime.mjs +function timeoutError2() { + const error = new Error("Enterprise WeChat WebSocket authentication timed out"); + error.code = "connect-timeout"; + return error; +} +function createWecomRuntimeStatus() { + return { + startedAt: null, + ready: false, + wecomConnectionState: "idle", + harnessReachable: false, + lastCheckedAt: null, + lastConnectedAt: null, + lastError: null, + ...createWecomBridgeStatus() + }; +} +var WecomRuntime = class { + #config; + #secret; + #harness; + #state; + #logger; + #replyTimeoutMs; + #connectTimeoutMs; + #maxReconnectAttempts; + #createClient; + #status = createWecomRuntimeStatus(); + #client = null; + #bridge = null; + #starting = null; + #startController = null; + constructor({ + config, + secret, + harness, + state, + logger = console, + replyTimeoutMs = 6e5, + connectTimeoutMs = 2e4, + maxReconnectAttempts = 10, + createClient = (options) => new WSClient(options) + }) { + if (!config || !secret || !harness || !state) { + throw new TypeError("WecomRuntime requires config, secret, Harness, and state"); + } + this.#config = config; + this.#secret = secret; + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#connectTimeoutMs = connectTimeoutMs; + this.#maxReconnectAttempts = maxReconnectAttempts; + this.#createClient = createClient; + } + get status() { + return structuredClone(this.#status); + } + async start() { + if (this.#status.ready && this.#client) return this.status; + if (this.#starting) return this.#starting; + const controller = new AbortController(); + this.#startController = controller; + this.#starting = this.#start(controller.signal).finally(() => { + if (this.#startController === controller) this.#startController = null; + this.#starting = null; + }); + return this.#starting; + } + async #start(signal) { + await this.#stopActive(); + signal.throwIfAborted(); + this.#status.startedAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.wecomConnectionState = "connecting"; + this.#status.lastError = null; + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + const silentSdkLogger = { debug() { + }, info() { + }, warn() { + }, error() { + } }; + const client = this.#createClient({ + botId: this.#config.remoteBotId, + secret: this.#secret, + logger: silentSdkLogger, + maxReconnectAttempts: this.#maxReconnectAttempts + }); + if (!client || typeof client.connect !== "function" || typeof client.disconnect !== "function") { + throw new TypeError("Enterprise WeChat client factory returned an invalid client"); + } + this.#client = client; + this.#bridge = new WecomHarnessBridge({ + client, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs + }); + let readyResolve; + let readyReject; + let authenticated = false; + const ready = new Promise((resolve6, reject) => { + readyResolve = resolve6; + readyReject = reject; + }); + const onAuthenticated = () => { + if (this.#client !== client || signal.aborted) return; + authenticated = true; + const now = Date.now(); + this.#status.ready = true; + this.#status.wecomConnectionState = "connected"; + this.#status.lastCheckedAt = now; + this.#status.lastConnectedAt = now; + this.#status.lastError = null; + readyResolve(); + }; + const onDisconnected = () => { + if (this.#client !== client) return; + this.#status.ready = false; + this.#status.wecomConnectionState = "connecting"; + this.#status.lastCheckedAt = Date.now(); + }; + const onReconnecting = () => { + if (this.#client !== client) return; + this.#status.ready = false; + this.#status.wecomConnectionState = "connecting"; + this.#status.lastCheckedAt = Date.now(); + }; + const onError = (error) => { + if (this.#client !== client) return; + const terminal = error instanceof WSAuthFailureError || error instanceof WSReconnectExhaustedError; + if (!authenticated && terminal) readyReject(error); + if (terminal) { + this.#status.ready = false; + this.#status.wecomConnectionState = "failed"; + } + this.#status.lastError = terminal ? error.name : "connection-error"; + this.#logger.warn?.(`[dsh-im:wecom] bot ${this.#config.botId} connection error`); + }; + const onMessage = (frame) => this.#bridge?.accept(frame); + client.on("authenticated", onAuthenticated); + client.on("disconnected", onDisconnected); + client.on("reconnecting", onReconnecting); + client.on("error", onError); + client.on("message", onMessage); + let timer; + try { + client.connect(); + await Promise.race([ + ready, + new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(timeoutError2()), this.#connectTimeoutMs); + }) + ]); + return this.status; + } catch (error) { + if (signal.aborted) { + await this.#stopActive(); + throw error; + } + this.#status.ready = false; + this.#status.wecomConnectionState = "failed"; + this.#status.lastError = error?.message ?? String(error); + await this.#stopActive(); + throw error; + } finally { + clearTimeout(timer); + } + } + async stop() { + const starting = this.#starting; + this.#startController?.abort(new DOMException("Enterprise WeChat runtime stopped", "AbortError")); + await this.#stopActive(); + await starting?.catch(() => void 0); + return this.status; + } + async #stopActive() { + const client = this.#client; + const bridge = this.#bridge; + this.#client = null; + this.#bridge = null; + try { + client?.disconnect(); + client?.removeAllListeners?.(); + } catch (error) { + this.#logger.warn?.(`[dsh-im:wecom] bot ${this.#config.botId} failed to stop cleanly`); + } + await bridge?.waitForIdle(); + this.#status.ready = false; + this.#status.wecomConnectionState = "idle"; + } +}; + +// plugin-src/host/channels/wecom/connection-supervisor.mjs +var DEFAULT_RETRY_DELAYS_MS4 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); +function retryDelays3(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS4]; + const valid = value.filter((delay2) => Number.isFinite(delay2) && delay2 >= 0); + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS4]; +} +var ConnectionSupervisor4 = class { + #controller; + #harness; + #logger; + #retryDelays; + #healthyIntervalMs; + #setTimeout; + #clearTimeout; + #timer = null; + #running = null; + #retryIndex = 0; + #closed = false; + #started = false; + #ready; + #resolveReady; + constructor({ + controller, + harness, + logger = console, + retryDelaysMs, + healthyIntervalMs = 15e3, + setTimeoutImpl = setTimeout, + clearTimeoutImpl = clearTimeout + }) { + if (!controller || typeof controller.initialize !== "function" || typeof controller.status !== "function") { + throw new TypeError("ConnectionSupervisor requires a controller"); + } + if (!harness || typeof harness.ensureRunning !== "function") { + throw new TypeError("ConnectionSupervisor requires a Harness client"); + } + this.#controller = controller; + this.#harness = harness; + this.#logger = logger; + this.#retryDelays = retryDelays3(retryDelaysMs); + this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; + this.#setTimeout = setTimeoutImpl; + this.#clearTimeout = clearTimeoutImpl; + this.#ready = new Promise((resolve6) => { + this.#resolveReady = resolve6; + }); + } + get ready() { + return this.#ready; + } + start() { + if (this.#started || this.#closed) return this; + this.#started = true; + this.#schedule(0); + return this; + } + async close() { + if (this.#closed) return; + this.#closed = true; + if (this.#timer !== null) this.#clearTimeout(this.#timer); + this.#timer = null; + await this.#running?.catch(() => void 0); + this.#resolveReady?.(null); + this.#resolveReady = null; + } + #schedule(delayMs) { + if (this.#closed) return; + this.#timer = this.#setTimeout(() => { + this.#timer = null; + void this.#run(); + }, delayMs); + this.#timer?.unref?.(); + } + async #run() { + if (this.#closed || this.#running) return; + const operation = this.#reconcile(); + this.#running = operation; + try { + await operation; + } finally { + if (this.#running === operation) this.#running = null; + } + } + async #reconcile() { + try { + await this.#harness.ensureRunning(); + if (this.#closed) return; + const status = await this.#controller.initialize(); + if (this.#closed) return; + this.#resolveReady?.(status); + this.#resolveReady = null; + const { configured, connected } = status.totals; + if (connected < configured) { + const delay2 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.(`[dsh-im:wecom] ${connected}/${configured} bots connected; retrying in ${delay2}ms`); + this.#schedule(delay2); + return; + } + this.#retryIndex = 0; + this.#schedule(this.#healthyIntervalMs); + } catch (error) { + if (this.#closed) return; + const delay2 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.(`[dsh-im:wecom] connection reconciliation failed; retrying in ${delay2}ms`, error); + this.#schedule(delay2); + } + } +}; +function createConnectionSupervisor4(options) { + return new ConnectionSupervisor4(options); +} + +// plugin-src/host/channels/wecom/production.mjs +function harnessOrigin4(webServer, configured) { + if (configured !== void 0) return new URL(configured); + const port = webServer?.port; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("dsh-im Enterprise WeChat requires an initialized DSH webServer port"); + } + return new URL(`http://127.0.0.1:${port}`); +} +function pluginPaths4(config) { + const dshHome = resolve4(config.dshHome ?? process.env.DSH_HOME ?? join4(homedir4(), ".dsh")); + const root = resolve4(config.dataDir ?? join4(dshHome, "integrations", "dsh-wecom")); + return { + config: resolve4(config.configPath ?? join4(root, "config.json")), + bots: resolve4(config.botsDir ?? join4(root, "bots")) + }; +} +async function createProductionController4(ctx, config = {}, internals = {}) { + if (!ctx?.credentials) throw new TypeError("dsh-im Enterprise WeChat requires ctx.credentials"); + if (!ctx?.webServer) throw new TypeError("dsh-im Enterprise WeChat requires ctx.webServer"); + const ConfigStore = internals.ConfigStore ?? WecomConfigStore; + const StateStore2 = internals.StateStore ?? WecomStateStore; + const Harness = internals.HarnessClient ?? WecomHarnessClient; + const Controller = internals.Controller ?? WecomController; + const Runtime = internals.Runtime ?? WecomRuntime; + const QrAuth = internals.QrAuth ?? WecomQrAuth; + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor4; + const logger = typeof ctx.logger === "function" ? ctx.logger("dsh-im:wecom") : ctx.logger ?? console; + const paths = pluginPaths4(config); + const configStore = await new ConfigStore(paths.config).load(); + const qrAuth = internals.qrAuth ?? new QrAuth({ + source: config.qrSource ?? "deepseek-harness", + platform: config.qrPlatform + }); + const stateStores = /* @__PURE__ */ new Map(); + const statePath = (botId) => resolve4(paths.bots, botId, "state.json"); + const stateFor = async (botId) => { + let state = stateStores.get(botId); + if (!state) { + state = await new StateStore2(statePath(botId)).load(); + stateStores.set(botId, state); + } + return state; + }; + const harness = new Harness({ + baseUrl: harnessOrigin4(ctx.webServer, config.harnessBaseUrl), + workspace: resolve4(config.workspace ?? process.cwd()), + agentPreset: config.agentPreset ?? "standard", + autostart: false, + dshBin: config.dshBin ?? "dsh" + }); + const controller = new Controller({ + qrAuth, + credentials: ctx.credentials, + configStore, + logger, + createRuntime: async ({ botId, config: botConfig, secret }) => new Runtime({ + config: botConfig, + secret, + harness, + state: await stateFor(botId), + replyTimeoutMs: config.replyTimeoutMs ?? 6e5, + connectTimeoutMs: config.connectTimeoutMs ?? 2e4, + maxReconnectAttempts: config.maxReconnectAttempts ?? 10, + logger: { + error: (...args) => logger.error?.(`[${botId}]`, ...args), + warn: (...args) => logger.warn?.(`[${botId}]`, ...args), + info: (...args) => logger.info?.(`[${botId}]`, ...args), + debug: (...args) => logger.debug?.(`[${botId}]`, ...args) + } + }), + deleteState: async ({ botId }) => { + const state = stateStores.get(botId); + stateStores.delete(botId); + if (state && typeof state.remove === "function") return state.remove(); + try { + await unlink11(statePath(botId)); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + }); + const supervisor = createSupervisor({ + controller, + harness, + logger, + retryDelaysMs: config.retryDelaysMs, + healthyIntervalMs: config.healthyIntervalMs + }).start(); + return { + controller, + ready: supervisor.ready, + async close() { + await supervisor.close(); + await controller.close(); + harness.stopManagedProcess(); + } + }; +} + +// plugin-src/host/channels/wecom/rpc.mjs +import QRCode4 from "qrcode"; +var WECOM_RPC_CHANNEL = "/wecom"; +var WECOM_ENDPOINTS = Object.freeze({ + status: "connection.status", + beginProvisioning: "provision.begin", + pollProvisioning: "provision.poll", + cancelProvisioning: "provision.cancel", + reconnectBot: "bot.reconnect", + deleteBot: "bot.delete" +}); +var WECOM_RPC_ENDPOINTS = Object.freeze(Object.values(WECOM_ENDPOINTS)); +var FORBIDDEN_PUBLIC_KEYS3 = /* @__PURE__ */ new Set([ + "secret", + "secretRef", + "scode", + "remoteBotId", + "verificationUrl", + "bot_info", + "botid" +]); +function isRecord3(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function exactKeys3(value, allowed) { + return isRecord3(value) && Object.keys(value).every((key) => allowed.includes(key)); +} +function validId3(value) { + return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); +} +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."; + if (endpoint === WECOM_ENDPOINTS.beginProvisioning) { + return exactKeys3(payload, ["locale"]) && (payload.locale === void 0 || payload.locale === "zh-CN") ? null : "provision.begin received unsupported fields."; + } + 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.reconnectBot) { + return exactKeys3(payload, ["botId"]) && validId3(payload.botId) ? null : "bot.reconnect requires a botId."; + } + if (endpoint === WECOM_ENDPOINTS.deleteBot) { + return exactKeys3(payload, ["botId", "confirm"]) && validId3(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; + } + return "Unknown Enterprise WeChat endpoint."; +} +function sanitizePublic3(value) { + if (Array.isArray(value)) return value.map(sanitizePublic3); + if (!isRecord3(value)) return value; + const safe = {}; + for (const [key, child] of Object.entries(value)) { + if (!FORBIDDEN_PUBLIC_KEYS3.has(key)) safe[key] = sanitizePublic3(child); + } + return safe; +} +async function qrDataUrl3(value) { + return QRCode4.toDataURL(value, { + type: "image/png", + errorCorrectionLevel: "M", + margin: 2, + width: 320 + }); +} +async function withEncodedQr3(value, encodeQr) { + if (!value || typeof value.verificationUrl !== "string") return sanitizePublic3(value); + return sanitizePublic3({ ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) }); +} +async function publicStatus3(status, encodeQr) { + const value = structuredClone(status); + if (value?.provisioning) value.provisioning = await withEncodedQr3(value.provisioning, encodeQr); + return sanitizePublic3(value); +} +function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { + for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "reconnectBot", "deleteBot"]) { + if (typeof controller?.[method] !== "function") { + throw new TypeError(`A complete Enterprise WeChat controller is required (${method})`); + } + } + const qrCache = /* @__PURE__ */ new Map(); + const cachedEncode = (url) => { + let encoded = qrCache.get(url); + if (!encoded) { + if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value); + encoded = Promise.resolve().then(() => encodeQr(url)); + qrCache.set(url, encoded); + } + return encoded; + }; + return async (endpoint, payload, signal) => { + if (signal?.aborted) return { ok: false, error: { code: "cancelled", message: "The request was cancelled." } }; + if (!WECOM_RPC_ENDPOINTS.includes(endpoint)) { + return { ok: false, error: { code: "bad-request", message: "Unknown Enterprise WeChat endpoint." } }; + } + const invalid = payloadFailure3(endpoint, payload); + if (invalid) return { ok: false, error: { code: "bad-request", message: invalid } }; + try { + let value; + if (endpoint === WECOM_ENDPOINTS.status) value = await publicStatus3(await controller.status(), cachedEncode); + else if (endpoint === WECOM_ENDPOINTS.beginProvisioning) { + value = await withEncodedQr3(await controller.startProvisioning(), cachedEncode); + } else if (endpoint === WECOM_ENDPOINTS.pollProvisioning) { + const current = await controller.registrationStatus(payload.attemptId); + if (!current) return { ok: false, error: { code: "bad-request", message: "The provisioning attempt no longer exists." } }; + value = await withEncodedQr3(current, cachedEncode); + } else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) { + value = sanitizePublic3(await controller.cancelProvisioning(payload.attemptId)); + } else if (endpoint === WECOM_ENDPOINTS.reconnectBot) { + value = await publicStatus3(await controller.reconnectBot(payload.botId), cachedEncode); + } else { + value = await publicStatus3(await controller.deleteBot(payload.botId), cachedEncode); + } + return signal?.aborted ? { ok: false, error: { code: "cancelled", message: "The request was cancelled." } } : { ok: true, value }; + } catch { + return signal?.aborted ? { ok: false, error: { code: "cancelled", message: "The request was cancelled." } } : { ok: false, error: { code: "wecom-operation-failed", message: "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" } }; + } + }; +} +function installWecomRpc(ctx, controller, options) { + if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== "function") { + throw new TypeError("DSH Host Connection RPC is required"); + } + return ctx.connection.rpc.handle( + WECOM_RPC_CHANNEL, + createWecomRpcHandler(controller, options), + { authority: "loopback" } + ); +} + +// plugin-src/host/channels/wecom/index.mjs +async function apply4(ctx, config = {}) { + if (config?.controller) return installWecomRpc(ctx, config.controller, config.rpcOptions); + const production = await createProductionController4(ctx, config, config.internals); + const disposeRpc = installWecomRpc(ctx, production.controller, config.rpcOptions); + ctx.effect(() => async () => production.close(), "dsh-im: close Enterprise WeChat bot connections"); + return disposeRpc; +} + +// plugin-src/host/channels/weixin/production.mjs +import { unlink as unlink14 } from "node:fs/promises"; +import { homedir as homedir5 } from "node:os"; +import { join as join5, resolve as resolve5 } from "node:path"; + +// src/channels/weixin/config-store.mjs +import { createHash as createHash5 } from "node:crypto"; +import { mkdir as mkdir9, readFile as readFile9, rename as rename9, unlink as unlink12, writeFile as writeFile9 } from "node:fs/promises"; +import { dirname as dirname9 } from "node:path"; // src/channels/weixin/weixin-api.mjs -import { randomBytes, randomUUID as randomUUID10 } from "node:crypto"; +import { randomBytes, randomUUID as randomUUID11 } from "node:crypto"; var WEIXIN_QR_BASE_URL = "https://ilinkai.weixin.qq.com/"; var WEIXIN_PROTOCOL_VERSION = "2.4.6"; var DEFAULT_BOT_TYPE = "3"; @@ -7647,7 +9138,7 @@ function abortError3(signal) { if (signal?.reason instanceof Error) return signal.reason; return new DOMException("The operation was aborted", "AbortError"); } -async function requestJson2(fetchImpl, { +async function requestJson3(fetchImpl, { method, baseUrl, endpoint, @@ -7713,7 +9204,7 @@ function createWeixinApi({ fetchImpl = fetch } = {}) { return Object.freeze({ async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) { const tokens = [...new Set(localTokens.map(nonEmptyString5).filter(Boolean))].slice(-10); - const response = await requestJson2(fetchImpl, { + const response = await requestJson3(fetchImpl, { method: "POST", baseUrl: WEIXIN_QR_BASE_URL, endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, @@ -7733,7 +9224,7 @@ function createWeixinApi({ fetchImpl = fetch } = {}) { if (!qr) throw new TypeError("qrcode is required"); let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr)}`; if (nonEmptyString5(verifyCode)) endpoint += `&verify_code=${encodeURIComponent(verifyCode.trim())}`; - const response = await requestJson2(fetchImpl, { + const response = await requestJson3(fetchImpl, { method: "GET", baseUrl, endpoint, @@ -7745,7 +9236,7 @@ function createWeixinApi({ fetchImpl = fetch } = {}) { }, async getUpdates({ baseUrl, token, getUpdatesBuf = "", timeoutMs, signal }) { try { - return await requestJson2(fetchImpl, { + return await requestJson3(fetchImpl, { method: "POST", baseUrl, endpoint: "ilink/bot/getupdates", @@ -7765,7 +9256,7 @@ function createWeixinApi({ fetchImpl = fetch } = {}) { const recipient = nonEmptyString5(toUserId); const content = nonEmptyString5(text); if (!recipient || !content) throw new TypeError("toUserId and text are required"); - const response = await requestJson2(fetchImpl, { + const response = await requestJson3(fetchImpl, { method: "POST", baseUrl, endpoint: "ilink/bot/sendmessage", @@ -7775,7 +9266,7 @@ function createWeixinApi({ fetchImpl = fetch } = {}) { msg: { from_user_id: "", to_user_id: recipient, - client_id: `dsh-weixin-${randomUUID10()}`, + client_id: `dsh-weixin-${randomUUID11()}`, message_type: 2, message_state: 2, item_list: [{ type: 1, text_item: { text: content } }], @@ -7791,7 +9282,7 @@ function createWeixinApi({ fetchImpl = fetch } = {}) { return true; }, async notifyStart({ baseUrl, token, signal }) { - const response = await requestJson2(fetchImpl, { + const response = await requestJson3(fetchImpl, { method: "POST", baseUrl, endpoint: "ilink/bot/msg/notifystart", @@ -7806,7 +9297,7 @@ function createWeixinApi({ fetchImpl = fetch } = {}) { return response; }, async notifyStop({ baseUrl, token, signal }) { - return requestJson2(fetchImpl, { + return requestJson3(fetchImpl, { method: "POST", baseUrl, endpoint: "ilink/bot/msg/notifystop", @@ -7852,36 +9343,36 @@ function splitWeixinText(text, maxChars = 4e3) { } // src/channels/weixin/config-store.mjs -var EMPTY_DOCUMENT3 = Object.freeze({ version: 1, accounts: Object.freeze([]) }); -function cleanString7(value) { +var EMPTY_DOCUMENT4 = Object.freeze({ version: 1, accounts: Object.freeze([]) }); +function cleanString10(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } function safeBotId3(value) { - const id = cleanString7(value); + const id = cleanString10(value); return id && /^wx_[a-f0-9]{24}$/.test(id) ? id : null; } function safeTokenRef(value) { - const ref = cleanString7(value); + const ref = cleanString10(value); return ref && /^DSH_WEIXIN_BOT_TOKEN_[A-F0-9]{24}$/.test(ref) ? ref : null; } function deriveWeixinBotIdentity(accountId) { - const raw = cleanString7(accountId); + const raw = cleanString10(accountId); if (!raw) throw new TypeError("accountId is required"); - const digest2 = createHash4("sha256").update(raw).digest("hex").slice(0, 24); + const digest2 = createHash5("sha256").update(raw).digest("hex").slice(0, 24); return { botId: `wx_${digest2}`, tokenRef: `DSH_WEIXIN_BOT_TOKEN_${digest2.toUpperCase()}` }; } function maskWeixinAccountId(accountId) { - const value = cleanString7(accountId) ?? ""; + const value = cleanString10(accountId) ?? ""; if (value.length <= 10) return value ? `${value.slice(0, 3)}\u2022\u2022\u2022` : "\u5FAE\u4FE1\u673A\u5668\u4EBA"; return `${value.slice(0, 6)}\u2022\u2022\u2022\u2022${value.slice(-4)}`; } function normalizeAccount(value) { if (!value || typeof value !== "object") return null; - const accountId = cleanString7(value.accountId); - const ownerUserId = cleanString7(value.ownerUserId); + const accountId = cleanString10(value.accountId); + const ownerUserId = cleanString10(value.ownerUserId); const botId = safeBotId3(value.botId); const tokenRef = safeTokenRef(value.tokenRef); if (!accountId || !ownerUserId || !botId || !tokenRef) return null; @@ -7899,11 +9390,11 @@ function normalizeAccount(value) { tokenRef, ownerUserId, baseUrl, - createdAt: cleanString7(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), - connectedAt: cleanString7(value.connectedAt) + createdAt: cleanString10(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: cleanString10(value.connectedAt) }); } -function normalizeDocument4(value) { +function normalizeDocument5(value) { if (!value || value.version !== 1 || !Array.isArray(value.accounts)) return null; const accounts = value.accounts.map(normalizeAccount); if (accounts.some((account) => account === null)) return null; @@ -7922,19 +9413,19 @@ function normalizeDocument4(value) { } var WeixinConfigStore = class { #path; - #value = EMPTY_DOCUMENT3; + #value = EMPTY_DOCUMENT4; #writeQueue = Promise.resolve(); constructor(path) { this.#path = path; } async load() { try { - const normalized = normalizeDocument4(JSON.parse(await readFile7(this.#path, "utf8"))); + const normalized = normalizeDocument5(JSON.parse(await readFile9(this.#path, "utf8"))); if (!normalized) throw new Error("dsh-weixin config contains invalid account data"); this.#value = normalized; } catch (error) { if (error?.code !== "ENOENT") throw error; - this.#value = EMPTY_DOCUMENT3; + this.#value = EMPTY_DOCUMENT4; } return this; } @@ -7978,11 +9469,11 @@ var WeixinConfigStore = class { async clear() { const operation = this.#writeQueue.then(async () => { try { - await unlink9(this.#path); + await unlink12(this.#path); } catch (error) { if (error?.code !== "ENOENT") throw error; } - this.#value = EMPTY_DOCUMENT3; + this.#value = EMPTY_DOCUMENT4; }); this.#writeQueue = operation.then(() => void 0, () => void 0); await operation; @@ -8001,28 +9492,28 @@ var WeixinConfigStore = class { return result; } async #write(document) { - await mkdir7(dirname7(this.#path), { recursive: true, mode: 448 }); + await mkdir9(dirname9(this.#path), { recursive: true, mode: 448 }); const temporary = `${this.#path}.tmp`; - await writeFile7(temporary, `${JSON.stringify(document, null, 2)} + await writeFile9(temporary, `${JSON.stringify(document, null, 2)} `, { encoding: "utf8", mode: 384 }); - await rename7(temporary, this.#path); + await rename9(temporary, this.#path); } }; // src/channels/weixin/state-store.mjs -import { mkdir as mkdir8, readFile as readFile8, rename as rename8, unlink as unlink10, writeFile as writeFile8 } from "node:fs/promises"; -import { dirname as dirname8 } from "node:path"; -var EMPTY_STATE4 = Object.freeze({ +import { mkdir as mkdir10, readFile as readFile10, rename as rename10, unlink as unlink13, writeFile as writeFile10 } from "node:fs/promises"; +import { dirname as dirname10 } from "node:path"; +var EMPTY_STATE5 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], getUpdatesBuf: "" }); -function normalizeState3(value) { - if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE4); +function normalizeState4(value) { + if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE5); const sessions = {}; if (value.sessions && typeof value.sessions === "object" && !Array.isArray(value.sessions)) { for (const [key, sessionId] of Object.entries(value.sessions)) { @@ -8040,17 +9531,17 @@ function normalizeState3(value) { } var WeixinStateStore = class { #path; - #state = structuredClone(EMPTY_STATE4); + #state = structuredClone(EMPTY_STATE5); #writeQueue = Promise.resolve(); constructor(path) { this.#path = path; } async load() { try { - this.#state = normalizeState3(JSON.parse(await readFile8(this.#path, "utf8"))); + this.#state = normalizeState4(JSON.parse(await readFile10(this.#path, "utf8"))); } catch (error) { if (error?.code !== "ENOENT") throw error; - this.#state = structuredClone(EMPTY_STATE4); + this.#state = structuredClone(EMPTY_STATE5); await this.#persist(); } return this; @@ -8090,20 +9581,20 @@ var WeixinStateStore = class { } async remove() { try { - await unlink10(this.#path); + await unlink13(this.#path); } catch (error) { if (error?.code !== "ENOENT") throw error; } - this.#state = structuredClone(EMPTY_STATE4); + this.#state = structuredClone(EMPTY_STATE5); } async #persist() { const snapshot = `${JSON.stringify(this.#state, null, 2)} `; const operation = this.#writeQueue.then(async () => { - await mkdir8(dirname8(this.#path), { recursive: true, mode: 448 }); + await mkdir10(dirname10(this.#path), { recursive: true, mode: 448 }); const temporary = `${this.#path}.tmp`; - await writeFile8(temporary, snapshot, { encoding: "utf8", mode: 384 }); - await rename8(temporary, this.#path); + await writeFile10(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename10(temporary, this.#path); }); this.#writeQueue = operation.then(() => void 0, () => void 0); await operation; @@ -8111,28 +9602,28 @@ var WeixinStateStore = class { }; // src/channels/weixin/weixin-controller.mjs -import { randomUUID as randomUUID11 } from "node:crypto"; -var ACTIVE_ATTEMPT_STATES3 = /* @__PURE__ */ new Set([ +import { randomUUID as randomUUID12 } from "node:crypto"; +var ACTIVE_ATTEMPT_STATES4 = /* @__PURE__ */ new Set([ "starting", "pending", "scanned", "needs_verification", "connecting" ]); -var TERMINAL_ATTEMPT_STATES3 = /* @__PURE__ */ new Set(["connected", "expired", "failed", "cancelled"]); -var QR_TTL_MS2 = 5 * 6e4; -function cleanString8(value) { +var TERMINAL_ATTEMPT_STATES4 = /* @__PURE__ */ new Set(["connected", "expired", "failed", "cancelled"]); +var QR_TTL_MS3 = 5 * 6e4; +function cleanString11(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } function abortError4() { return new DOMException("Provisioning was cancelled", "AbortError"); } function apiBaseFromServer(value, fallback) { - const raw = cleanString8(value); + const raw = cleanString11(value); if (!raw) return normalizeWeixinApiBaseUrl(fallback); return normalizeWeixinApiBaseUrl(raw.includes("://") ? raw : `https://${raw}`); } -function publicAttempt3(record) { +function publicAttempt4(record) { if (!record) return null; return { attemptId: record.id, @@ -8225,10 +9716,10 @@ var WeixinController = class { if (this.#closed) throw new Error("dsh-weixin controller is closed"); if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); const record = { - id: randomUUID11(), + id: randomUUID12(), state: "starting", createdAt: Date.now(), - expiresAt: Date.now() + QR_TTL_MS2, + expiresAt: Date.now() + QR_TTL_MS3, controller: new AbortController(), pendingVerifyCode: null, verifyResolve: null, @@ -8252,10 +9743,10 @@ var WeixinController = class { record.qrcode = login.qrcode; record.verificationUrl = login.qrcodeUrl; record.state = "pending"; - record.expiresAt = Date.now() + QR_TTL_MS2; + record.expiresAt = Date.now() + QR_TTL_MS3; this.#touch(); record.task = this.#runProvisioning(record); - return publicAttempt3(record); + return publicAttempt4(record); } catch (error) { if (record.controller.signal.aborted) { record.state = "cancelled"; @@ -8273,14 +9764,14 @@ var WeixinController = class { } } registrationStatus(attemptId) { - return publicAttempt3(this.#attempts.get(attemptId)); + return publicAttempt4(this.#attempts.get(attemptId)); } async submitVerification(attemptId, verifyCode) { const record = this.#attempts.get(attemptId); if (!record || record.state !== "needs_verification") { throw new Error("The provisioning attempt is not waiting for a verification code"); } - const code = cleanString8(verifyCode); + const code = cleanString11(verifyCode); if (!code || !/^\d{4,8}$/.test(code)) { throw new TypeError("Verification code must contain 4 to 8 digits"); } @@ -8289,22 +9780,22 @@ var WeixinController = class { record.verifyResolve?.(); record.verifyResolve = null; this.#touch(); - return publicAttempt3(record); + return publicAttempt4(record); } async cancelProvisioning(attemptId) { const record = this.#attempts.get(attemptId); if (!record) return null; - if (!TERMINAL_ATTEMPT_STATES3.has(record.state)) { + if (!TERMINAL_ATTEMPT_STATES4.has(record.state)) { record.controller.abort(); record.verifyResolve?.(); record.verifyResolve = null; await record.task?.catch(() => void 0); - if (!TERMINAL_ATTEMPT_STATES3.has(record.state)) record.state = "cancelled"; + if (!TERMINAL_ATTEMPT_STATES4.has(record.state)) record.state = "cancelled"; record.error ??= safeAccountError("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); } if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; this.#touch(); - return publicAttempt3(record); + return publicAttempt4(record); } async reconnectBot(botId) { const config = this.#configStore.get(botId); @@ -8382,10 +9873,10 @@ var WeixinController = class { return { schemaVersion: 1, revision: this.#revision, - state: active && ACTIVE_ATTEMPT_STATES3.has(active.state) ? "provisioning" : accounts.length === 0 ? "disconnected" : connectedCount === accounts.length ? "connected" : connectedCount > 0 ? "degraded" : "offline", + state: active && ACTIVE_ATTEMPT_STATES4.has(active.state) ? "provisioning" : accounts.length === 0 ? "disconnected" : connectedCount === accounts.length ? "connected" : connectedCount > 0 ? "degraded" : "offline", bots: accounts, totals: { configured: accounts.length, connected: connectedCount }, - ...active && ACTIVE_ATTEMPT_STATES3.has(active.state) ? { provisioning: publicAttempt3(active) } : {} + ...active && ACTIVE_ATTEMPT_STATES4.has(active.state) ? { provisioning: publicAttempt4(active) } : {} }; } async close() { @@ -8398,9 +9889,9 @@ var WeixinController = class { try { while (!record.controller.signal.aborted && Date.now() < record.expiresAt) { if (record.state === "needs_verification" && !record.pendingVerifyCode) { - await new Promise((resolve5) => { - record.verifyResolve = resolve5; - if (record.controller.signal.aborted) resolve5(); + await new Promise((resolve6) => { + record.verifyResolve = resolve6; + if (record.controller.signal.aborted) resolve6(); }); record.verifyResolve = null; this.#assertAttemptActive(record); @@ -8445,9 +9936,9 @@ var WeixinController = class { } break; } else if (response.status === "confirmed") { - const token = cleanString8(response.bot_token); - const accountId = cleanString8(response.ilink_bot_id); - const ownerUserId = cleanString8(response.ilink_user_id); + const token = cleanString11(response.bot_token); + const accountId = cleanString11(response.ilink_bot_id); + const ownerUserId = cleanString11(response.ilink_user_id); if (!token || !accountId || !ownerUserId) { throw new WeixinApiError("incomplete-login", "\u5FAE\u4FE1\u6388\u6743\u6210\u529F\uFF0C\u4F46\u8FD4\u56DE\u7684\u8D26\u53F7\u51ED\u636E\u4E0D\u5B8C\u6574\u3002"); } @@ -8466,7 +9957,7 @@ var WeixinController = class { } this.#touch(); } - if (!record.controller.signal.aborted && Date.now() >= record.expiresAt && !TERMINAL_ATTEMPT_STATES3.has(record.state)) { + if (!record.controller.signal.aborted && Date.now() >= record.expiresAt && !TERMINAL_ATTEMPT_STATES4.has(record.state)) { record.state = "expired"; record.error = safeAccountError("expired", "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); } @@ -8552,7 +10043,7 @@ var WeixinController = class { } async #resolveToken(ref) { const result = await this.#credentials.resolve(ref).catch(() => void 0); - return cleanString8(result?.value); + return cleanString11(result?.value); } async #restoreCredential(ref, previous) { try { @@ -8576,7 +10067,7 @@ var WeixinController = class { } #pruneAttempts() { for (const [id, record] of this.#attempts) { - if (id !== this.#activeAttemptId && TERMINAL_ATTEMPT_STATES3.has(record.state) && this.#attempts.size > 16) { + if (id !== this.#activeAttemptId && TERMINAL_ATTEMPT_STATES4.has(record.state) && this.#attempts.size > 16) { this.#attempts.delete(id); } } @@ -8587,7 +10078,7 @@ var WeixinController = class { }; // src/channels/weixin/weixin-bridge.mjs -var HELP_TEXT4 = [ +var HELP_TEXT5 = [ "\u5FAE\u4FE1\u5DF2\u8FDE\u63A5 DeepSeek Harness\u3002", "", "\u76F4\u63A5\u53D1\u9001\u6587\u5B57\u6216\u5E26\u6587\u5B57\u8BC6\u522B\u7ED3\u679C\u7684\u8BED\u97F3\u5373\u53EF\u7EE7\u7EED\u5F53\u524D\u4F1A\u8BDD\u3002", @@ -8595,7 +10086,7 @@ var HELP_TEXT4 = [ "/status \u68C0\u67E5\u8FDE\u63A5\u72B6\u6001", "/help \u663E\u793A\u672C\u5E2E\u52A9" ].join("\n"); -function conversationKey4(userId) { +function conversationKey5(userId) { return `p2p:${userId}`; } function createWeixinBridgeStatus() { @@ -8686,7 +10177,7 @@ var WeixinHarnessBridge = class { } const command = text.trim().toLowerCase(); if (command === "/help") { - await this.#send(sender, HELP_TEXT4, contextToken, runId); + await this.#send(sender, HELP_TEXT5, contextToken, runId); await this.#state.markSeen(messageId); return; } @@ -8697,12 +10188,12 @@ var WeixinHarnessBridge = class { return; } if (command === "/new") { - await this.#state.clearSession(conversationKey4(sender)); + await this.#state.clearSession(conversationKey5(sender)); await this.#send(sender, "\u5DF2\u5F00\u542F\u65B0\u4F1A\u8BDD\u3002\u8BF7\u53D1\u9001\u4F60\u7684\u95EE\u9898\u3002", contextToken, runId); await this.#state.markSeen(messageId); return; } - const key = conversationKey4(sender); + const key = conversationKey5(sender); let sessionId = this.#state.sessionFor(key); if (!sessionId || !await this.#harness.sessionExists(sessionId)) { sessionId = await this.#harness.createSession(); @@ -8741,14 +10232,14 @@ var WeixinHarnessBridge = class { // src/channels/weixin/weixin-runtime.mjs function delay(ms, signal) { - return new Promise((resolve5, reject) => { + return new Promise((resolve6, reject) => { if (signal?.aborted) { reject(signal.reason ?? new DOMException("Aborted", "AbortError")); return; } const finish = () => { signal?.removeEventListener("abort", onAbort); - resolve5(); + resolve6(); }; const timer = setTimeout(finish, ms); const onAbort = () => { @@ -8933,13 +10424,13 @@ var WeixinRuntime = class { }; // plugin-src/host/channels/weixin/connection-supervisor.mjs -var DEFAULT_RETRY_DELAYS_MS4 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); -function retryDelays3(value) { - if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS4]; +var DEFAULT_RETRY_DELAYS_MS5 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); +function retryDelays4(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS5]; const valid = value.filter((delay2) => Number.isFinite(delay2) && delay2 >= 0); - return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS4]; + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS5]; } -var ConnectionSupervisor4 = class { +var ConnectionSupervisor5 = class { #controller; #harness; #logger; @@ -8972,12 +10463,12 @@ var ConnectionSupervisor4 = class { this.#controller = controller; this.#harness = harness; this.#logger = logger; - this.#retryDelays = retryDelays3(retryDelaysMs); + this.#retryDelays = retryDelays4(retryDelaysMs); this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve5) => { - this.#resolveReady = resolve5; + this.#ready = new Promise((resolve6) => { + this.#resolveReady = resolve6; }); } get ready() { @@ -9045,12 +10536,12 @@ var ConnectionSupervisor4 = class { } } }; -function createConnectionSupervisor4(options) { - return new ConnectionSupervisor4(options); +function createConnectionSupervisor5(options) { + return new ConnectionSupervisor5(options); } // plugin-src/host/channels/weixin/production.mjs -function harnessOrigin4(webServer, configured) { +function harnessOrigin5(webServer, configured) { if (configured !== void 0) return new URL(configured); const port = webServer?.port; if (!Number.isInteger(port) || port < 1 || port > 65535) { @@ -9058,16 +10549,16 @@ function harnessOrigin4(webServer, configured) { } return new URL(`http://127.0.0.1:${port}`); } -function pluginPaths4(config) { - const dshHome = resolve4(config.dshHome ?? process.env.DSH_HOME ?? join4(homedir4(), ".dsh")); - const root = resolve4(config.dataDir ?? join4(dshHome, "integrations", "dsh-weixin")); +function pluginPaths5(config) { + const dshHome = resolve5(config.dshHome ?? process.env.DSH_HOME ?? join5(homedir5(), ".dsh")); + const root = resolve5(config.dataDir ?? join5(dshHome, "integrations", "dsh-weixin")); return { root, - config: resolve4(config.configPath ?? join4(root, "config.json")), - accounts: resolve4(config.accountsDir ?? join4(root, "accounts")) + config: resolve5(config.configPath ?? join5(root, "config.json")), + accounts: resolve5(config.accountsDir ?? join5(root, "accounts")) }; } -async function createProductionController4(ctx, config = {}, internals = {}) { +async function createProductionController5(ctx, config = {}, internals = {}) { if (!ctx?.credentials) throw new TypeError("dsh-weixin requires ctx.credentials"); if (!ctx?.webServer) throw new TypeError("dsh-weixin requires ctx.webServer"); const ConfigStore = internals.ConfigStore ?? WeixinConfigStore; @@ -9076,12 +10567,12 @@ async function createProductionController4(ctx, config = {}, internals = {}) { const Controller = internals.Controller ?? WeixinController; const Runtime = internals.Runtime ?? WeixinRuntime; const api = internals.api ?? createWeixinApi(); - const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor4; + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor5; const logger = typeof ctx.logger === "function" ? ctx.logger("dsh-weixin") : ctx.logger ?? console; - const paths = pluginPaths4(config); + const paths = pluginPaths5(config); const configStore = await new ConfigStore(paths.config).load(); const stateStores = /* @__PURE__ */ new Map(); - const statePath = (botId) => resolve4(paths.accounts, botId, "state.json"); + const statePath = (botId) => resolve5(paths.accounts, botId, "state.json"); const stateFor = async (botId) => { let state = stateStores.get(botId); if (!state) { @@ -9091,8 +10582,8 @@ async function createProductionController4(ctx, config = {}, internals = {}) { return state; }; const harness = new Harness({ - baseUrl: harnessOrigin4(ctx.webServer, config.harnessBaseUrl), - workspace: resolve4(config.workspace ?? process.cwd()), + baseUrl: harnessOrigin5(ctx.webServer, config.harnessBaseUrl), + workspace: resolve5(config.workspace ?? process.cwd()), agentPreset: config.agentPreset ?? "standard", autostart: false, dshBin: config.dshBin ?? "dsh" @@ -9128,7 +10619,7 @@ async function createProductionController4(ctx, config = {}, internals = {}) { return; } try { - await unlink11(statePath(botId)); + await unlink14(statePath(botId)); } catch (error) { if (error?.code !== "ENOENT") throw error; } @@ -9153,7 +10644,7 @@ async function createProductionController4(ctx, config = {}, internals = {}) { } // plugin-src/host/channels/weixin/rpc.mjs -import QRCode4 from "qrcode"; +import QRCode5 from "qrcode"; var WEIXIN_RPC_CHANNEL = "/weixin"; var WEIXIN_ENDPOINTS = Object.freeze({ status: "connection.status", @@ -9165,34 +10656,34 @@ var WEIXIN_ENDPOINTS = Object.freeze({ deleteBot: "bot.delete" }); var WEIXIN_RPC_ENDPOINTS = Object.freeze(Object.values(WEIXIN_ENDPOINTS)); -function isRecord3(value) { +function isRecord4(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } -function exactKeys3(value, allowed) { - return isRecord3(value) && Object.keys(value).every((key) => allowed.includes(key)); +function exactKeys4(value, allowed) { + return isRecord4(value) && Object.keys(value).every((key) => allowed.includes(key)); } -function validId3(value) { +function validId4(value) { return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); } -function payloadFailure3(endpoint, payload) { - if (!isRecord3(payload)) return "Payload must be an object."; +function payloadFailure4(endpoint, payload) { + if (!isRecord4(payload)) return "Payload must be an object."; if (endpoint === WEIXIN_ENDPOINTS.status) { - return exactKeys3(payload, []) ? null : "connection.status does not accept fields."; + return exactKeys4(payload, []) ? null : "connection.status does not accept fields."; } if (endpoint === WEIXIN_ENDPOINTS.beginProvisioning) { - return exactKeys3(payload, ["locale"]) && (payload.locale === void 0 || payload.locale === "zh-CN") ? null : "provision.begin received unsupported fields."; + return exactKeys4(payload, ["locale"]) && (payload.locale === void 0 || payload.locale === "zh-CN") ? null : "provision.begin received unsupported fields."; } if ([WEIXIN_ENDPOINTS.pollProvisioning, WEIXIN_ENDPOINTS.cancelProvisioning].includes(endpoint)) { - return exactKeys3(payload, ["attemptId"]) && validId3(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; + return exactKeys4(payload, ["attemptId"]) && validId4(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; } if (endpoint === WEIXIN_ENDPOINTS.submitVerification) { - return exactKeys3(payload, ["attemptId", "verifyCode"]) && validId3(payload.attemptId) && typeof payload.verifyCode === "string" && /^\d{4,8}$/.test(payload.verifyCode) ? null : "provision.verify requires an attemptId and a 4-to-8-digit code."; + return exactKeys4(payload, ["attemptId", "verifyCode"]) && validId4(payload.attemptId) && typeof payload.verifyCode === "string" && /^\d{4,8}$/.test(payload.verifyCode) ? null : "provision.verify requires an attemptId and a 4-to-8-digit code."; } if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) { - return exactKeys3(payload, ["botId"]) && validId3(payload.botId) ? null : "bot.reconnect requires a botId."; + return exactKeys4(payload, ["botId"]) && validId4(payload.botId) ? null : "bot.reconnect requires a botId."; } if (endpoint === WEIXIN_ENDPOINTS.deleteBot) { - return exactKeys3(payload, ["botId", "confirm"]) && validId3(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; + return exactKeys4(payload, ["botId", "confirm"]) && validId4(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; } return "Unknown Weixin endpoint."; } @@ -9208,24 +10699,24 @@ function internalFailure3() { error: { code: "weixin-operation-failed", message: "\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" } }; } -async function qrDataUrl3(value) { - return QRCode4.toDataURL(value, { +async function qrDataUrl4(value) { + return QRCode5.toDataURL(value, { type: "image/png", errorCorrectionLevel: "M", margin: 2, width: 320 }); } -async function withEncodedQr3(value, encodeQr) { +async function withEncodedQr4(value, encodeQr) { if (!value || !value.verificationUrl) return value; return { ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) }; } -async function publicStatus3(status, encodeQr) { +async function publicStatus4(status, encodeQr) { const safe = structuredClone(status); - if (safe.provisioning) safe.provisioning = await withEncodedQr3(safe.provisioning, encodeQr); + if (safe.provisioning) safe.provisioning = await withEncodedQr4(safe.provisioning, encodeQr); return safe; } function assertController3(controller) { @@ -9233,7 +10724,7 @@ function assertController3(controller) { throw new TypeError("A complete Weixin controller is required"); } } -function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { +function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl4 } = {}) { assertController3(controller); const qrCache = /* @__PURE__ */ new Map(); const cachedEncode = (url) => { @@ -9248,25 +10739,25 @@ function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { return async (endpoint, payload, signal) => { if (signal?.aborted) return cancelled3(); if (!WEIXIN_RPC_ENDPOINTS.includes(endpoint)) return badRequest3("Unknown Weixin endpoint."); - const invalid = payloadFailure3(endpoint, payload); + const invalid = payloadFailure4(endpoint, payload); if (invalid) return badRequest3(invalid); try { let value; if (endpoint === WEIXIN_ENDPOINTS.status) { - value = await publicStatus3(await controller.status(), cachedEncode); + value = await publicStatus4(await controller.status(), cachedEncode); } else if (endpoint === WEIXIN_ENDPOINTS.beginProvisioning) { const started = await controller.startProvisioning(); if (signal?.aborted) { await controller.cancelProvisioning(started.attemptId); return cancelled3(); } - value = await withEncodedQr3(started, cachedEncode); + value = await withEncodedQr4(started, cachedEncode); } else if (endpoint === WEIXIN_ENDPOINTS.pollProvisioning) { const current = await controller.registrationStatus(payload.attemptId); if (!current) return badRequest3("The provisioning attempt no longer exists."); - value = await withEncodedQr3(current, cachedEncode); + value = await withEncodedQr4(current, cachedEncode); } else if (endpoint === WEIXIN_ENDPOINTS.submitVerification) { - value = await withEncodedQr3( + value = await withEncodedQr4( await controller.submitVerification(payload.attemptId, payload.verifyCode), cachedEncode ); @@ -9274,9 +10765,9 @@ function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { value = await controller.cancelProvisioning(payload.attemptId); if (!value) return badRequest3("The provisioning attempt no longer exists."); } else if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) { - value = await publicStatus3(await controller.reconnectBot(payload.botId), cachedEncode); + value = await publicStatus4(await controller.reconnectBot(payload.botId), cachedEncode); } else { - value = await publicStatus3(await controller.deleteBot(payload.botId), cachedEncode); + value = await publicStatus4(await controller.deleteBot(payload.botId), cachedEncode); } return signal?.aborted ? cancelled3() : { ok: true, value }; } catch { @@ -9296,9 +10787,9 @@ function installWeixinRpc(ctx, controller, options) { } // plugin-src/host/channels/weixin/index.mjs -async function apply4(ctx, config = {}) { +async function apply5(ctx, config = {}) { if (config?.controller) return installWeixinRpc(ctx, config.controller, config.rpcOptions); - const production = await createProductionController4(ctx, config, config.internals); + const production = await createProductionController5(ctx, config, config.internals); const disposeRpc = installWeixinRpc(ctx, production.controller, config.rpcOptions); ctx.effect(() => async () => { await production.close(); @@ -9311,8 +10802,9 @@ var name = "dsh-im-host"; var inject = ["connection", "credentials", "webServer"]; function createImHostPlugin(internals = {}) { const startFeishu = internals.applyFeishu ?? apply2; - const startWeixin = internals.applyWeixin ?? apply4; + const startWeixin = internals.applyWeixin ?? apply5; const startDingtalk = internals.applyDingtalk ?? apply; + const startWecom = internals.applyWecom ?? apply4; const startQq = internals.applyQq ?? apply3; return Object.freeze({ name, @@ -9321,15 +10813,16 @@ function createImHostPlugin(internals = {}) { await startFeishu(ctx, config.feishu ?? {}); await startWeixin(ctx, config.weixin ?? {}); await startDingtalk(ctx, config.dingtalk ?? {}); + await startWecom(ctx, config.wecom ?? {}); await startQq(ctx, config.qq ?? {}); } }); } -async function apply5(ctx, config = {}) { +async function apply6(ctx, config = {}) { return createImHostPlugin().apply(ctx, config); } export { - apply5 as apply, + apply6 as apply, createImHostPlugin, inject, name diff --git a/package-lock.json b/package-lock.json index c38084f..9dc771b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@larksuiteoapi/node-sdk": "1.73.0", "@tencent-connect/qqbot-connector": "1.2.0", "@tencent-connect/qqbot-nodejs": "1.0.4", + "@wecom/aibot-node-sdk": "1.0.7", "dingtalk-stream": "2.1.4", "qrcode": "1.5.4" }, @@ -587,6 +588,17 @@ "undici-types": "~8.3.0" } }, + "node_modules/@wecom/aibot-node-sdk": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@wecom/aibot-node-sdk/-/aibot-node-sdk-1.0.7.tgz", + "integrity": "sha512-51w+sTqunry6GD3HFvmuh0gArMSJDFE418vyvR1wMJHj1N6DaFuGD3HuaY2fazZs3mb9FWeQFX3+vU5t0Qhwmw==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.7", + "eventemitter3": "^5.0.1", + "ws": "^8.16.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -879,6 +891,12 @@ "@esbuild/win32-x64": "0.25.9" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", diff --git a/package.json b/package.json index 6505b50..0b16253 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": { @@ -60,6 +60,7 @@ "@larksuiteoapi/node-sdk": "1.73.0", "@tencent-connect/qqbot-connector": "1.2.0", "@tencent-connect/qqbot-nodejs": "1.0.4", + "@wecom/aibot-node-sdk": "1.0.7", "dingtalk-stream": "2.1.4", "qrcode": "1.5.4" }, diff --git a/plugin-src/client/channel-logos.js b/plugin-src/client/channel-logos.js index 1b74e3b..82c8fb5 100644 --- a/plugin-src/client/channel-logos.js +++ b/plugin-src/client/channel-logos.js @@ -57,3 +57,25 @@ export function QqLogoGlyph({ size } = {}) { d: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673', })); } + +export function WecomLogoGlyph({ size } = {}) { + return h('svg', { + ...dimensions(size), + viewBox: '0 0 24 24', + focusable: 'false', + 'aria-hidden': 'true', + 'data-im-channel-logo': 'wecom', + }, + h('path', { + fill: 'none', + stroke: '#3370FF', + strokeWidth: '2.35', + strokeLinecap: 'round', + strokeLinejoin: 'round', + d: 'M17.7 14.5c1.05-1.12 1.65-2.52 1.65-4.03 0-3.82-3.58-6.92-8-6.92s-8 3.1-8 6.92 3.58 6.92 8 6.92c1.17 0 2.28-.22 3.28-.62', + }), + h('path', { fill: '#07C160', d: 'M16.1 15.15c.7-.7 1.83-.7 2.53 0s.7 1.83 0 2.53-1.83.7-2.53 0-.7-1.83 0-2.53Z' }), + h('path', { fill: '#FFB800', d: 'M19.25 13.45a1.36 1.36 0 1 1 1.92 1.92 1.36 1.36 0 0 1-1.92-1.92Z' }), + h('path', { fill: '#FF7A00', d: 'M19.55 18.05a1.16 1.16 0 1 1 1.64 1.64 1.16 1.16 0 0 1-1.64-1.64Z' }), + h('path', { fill: '#3370FF', d: 'M15.25 18.75a.92.92 0 1 1 1.3 1.3.92.92 0 0 1-1.3-1.3Z' })); +} diff --git a/plugin-src/client/channels/wecom/api.js b/plugin-src/client/channels/wecom/api.js new file mode 100644 index 0000000..7f81850 --- /dev/null +++ b/plugin-src/client/channels/wecom/api.js @@ -0,0 +1,118 @@ +export const WECOM_RPC_CHANNEL = '/wecom'; + +export const WECOM_ENDPOINTS = Object.freeze({ + status: 'connection.status', + beginProvisioning: 'provision.begin', + pollProvisioning: 'provision.poll', + cancelProvisioning: 'provision.cancel', + reconnectBot: 'bot.reconnect', + deleteBot: 'bot.delete', +}); + +const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']); +const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']); +const QR_DATA_URL = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i; + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function text(value, fallback, max = 240) { + return typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : fallback; +} + +function id(value) { + const result = text(value, '', 128); + return /^[a-z\d_-]+$/i.test(result) ? result : undefined; +} + +function timestamp(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN; + return Number.isNaN(parsed) ? undefined : parsed; +} + +export function unwrapRpcResult(result) { + if (!isRecord(result) || typeof result.ok !== 'boolean') throw new Error('企业微信服务返回了无法识别的响应'); + if (!result.ok) { + const error = new Error(text(result.error?.message, '企业微信操作失败')); + error.code = text(result.error?.code, 'WECOM_RPC_ERROR', 80); + throw error; + } + return result.value; +} + +export function safeQrSource(value) { + return typeof value === 'string' && value.length <= 2 * 1024 * 1024 && QR_DATA_URL.test(value) + ? value : undefined; +} + +export function normalizeProvisioning(value, now = Date.now()) { + const source = isRecord(value?.provisioning) ? value.provisioning : value; + if (!isRecord(source)) throw new Error('企业微信服务没有返回扫码绑定进度'); + const attemptId = id(source.attemptId); + if (!attemptId) throw new Error('企业微信扫码服务没有返回有效的绑定任务'); + const reported = text(source.status, 'failed', 32); + const result = { + attemptId, + status: PROVISION_STATES.has(reported) ? reported : 'failed', + expiresAt: timestamp(source.expiresAt) ?? now + 5 * 60_000, + pollIntervalMs: Math.min(10_000, Math.max(500, Number(source.pollIntervalMs) || 1_000)), + qrRevision: Number.isSafeInteger(source.qrRevision) ? source.qrRevision : 0, + }; + const qrCodeDataUrl = safeQrSource(source.qrCodeDataUrl); + if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl; + if (id(source.botId)) result.botId = id(source.botId); + if (isRecord(source.error)) result.error = { + code: text(source.error.code, 'WECOM_PROVISION_FAILED', 80), + message: text(source.error.message, '企业微信机器人没有接入完成'), + }; + return result; +} + +function normalizeBot(value) { + if (!isRecord(value) || !id(value.botId)) return undefined; + const connected = value.connected === true; + const state = ACCOUNT_STATES.has(value.state) ? value.state : 'offline'; + return { + botId: id(value.botId), + connected, + state: connected ? 'connected' : state, + bot: { + name: text(value.bot?.name, '企业微信机器人', 100), + appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140), + }, + health: { + summary: text(value.health?.summary, connected ? '企业微信 WebSocket 长连接运行正常' : '企业微信连接尚未就绪'), + lastCheckedAt: timestamp(value.health?.lastCheckedAt), + }, + error: isRecord(value.error) ? { + code: text(value.error.code, 'WECOM_ACCOUNT_ERROR', 80), + message: text(value.error.message, '企业微信连接尚未就绪'), + } : null, + }; +} + +export function normalizeSnapshot(value) { + const source = isRecord(value?.snapshot) ? value.snapshot : value; + if (!isRecord(source) || !Array.isArray(source.bots)) throw new Error('企业微信服务没有返回有效的机器人列表'); + const bots = source.bots.map(normalizeBot).filter(Boolean); + return { + revision: Number.isSafeInteger(source.revision) ? source.revision : 0, + bots, + totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length }, + provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null, + }; +} + +export function presentError(error) { + return { + code: text(error?.code, 'WECOM_ERROR', 80), + message: text(error?.message, '企业微信操作失败,请稍后重试'), + }; +} + +export function formatRemaining(milliseconds) { + const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1_000) || 0); + return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; +} diff --git a/plugin-src/client/channels/wecom/index.js b/plugin-src/client/channels/wecom/index.js new file mode 100644 index 0000000..98fa64e --- /dev/null +++ b/plugin-src/client/channels/wecom/index.js @@ -0,0 +1,344 @@ +import * as React from 'react'; + +import { WecomLogoGlyph } from '../../channel-logos.js'; +import { installDingtalkStyles } from '../dingtalk/styles.js'; +import { + WECOM_ENDPOINTS, + WECOM_RPC_CHANNEL, + formatRemaining, + normalizeProvisioning, + normalizeSnapshot, + presentError, + safeQrSource, + unwrapRpcResult, +} from './api.js'; +import { installWecomStyles } from './styles.js'; + +const h = React.createElement; +const ACTIVE_STATES = new Set(['pending', 'refreshing', 'connecting']); + +const Button = React.forwardRef(function Button({ children, kind = 'secondary', className = '', ...props }, ref) { + return h('button', { + ...props, + ref, + type: 'button', + className: `ddt-button ${className}`.trim(), + 'data-kind': kind, + }, children); +}); + +function checkedTime(value) { + if (!value) return '尚未检查'; + try { + return new Intl.DateTimeFormat('zh-CN', { + hour: '2-digit', minute: '2-digit', second: '2-digit', + }).format(new Date(value)); + } catch { + return '刚刚'; + } +} + +function Heading({ totals, adding, busy, onAdd, 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 ? '正在绑定' : '扫码绑定企业微信机器人'), + totals.configured > 0 + ? h('div', { className: 'ddt-badge dim-onlineBadge' }, + h('span', null, `${totals.connected} / ${totals.configured} 在线`)) + : null)); +} + +function LoadingView() { + return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView', 'aria-busy': 'true' }, + h('div', { className: 'ddt-spinner dim-spinner' }), + h('span', null, '正在读取企业微信机器人状态…')); +} + +function EmptyView({ busy, onStart }) { + return h('div', { className: 'ddt-card dim-surfaceCard' }, + h('div', { className: 'ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView' }, + h('div', { className: 'dim-emptyCopy' }, + h('div', { className: 'ddt-stateLabel dim-stateLabel' }, + h('span', { className: 'ddt-dot dim-stateDot' }), h('span', null, '尚未绑定企业微信机器人')), + h('h3', null, '使用企业微信 App 扫码创建智能机器人'), + h('p', null, '扫码由腾讯官方页面完成,不需要手动填写 Bot ID 或 Secret。创建成功后,机器人会自动连接 DeepSeek Harness。'), + h('div', { className: 'ddt-actions dim-viewActions' }, + h(Button, { kind: 'primary', onClick: onStart, disabled: busy }, + busy ? '正在生成二维码…' : '生成企业微信二维码'))), + h('div', { className: 'ddt-brandMark dim-emptyBrand dwecom-brand', 'aria-hidden': 'true' }, + h(WecomLogoGlyph, { size: 64 })))); +} + +function QrPanel({ provision, now, busy, onRefresh, onCancel }) { + const source = safeQrSource(provision.qrCodeDataUrl); + const remaining = Math.max(0, provision.expiresAt - now); + const duration = Math.max(1, provision.durationMs ?? 5 * 60_000); + const progress = Math.round(Math.min(1, remaining / duration) * 100); + const refreshing = provision.status === 'refreshing'; + return h('div', { className: 'ddt-card dim-surfaceCard' }, + h('div', { className: 'ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout' }, + h('div', { className: 'ddt-qrColumn dim-qrColumn' }, + h('div', { className: 'ddt-qrFrame dim-qrFrame' }, + source ? h('img', { src: source, alt: '用于绑定企业微信机器人的一次性二维码' }) + : h('div', { className: 'ddt-qrFallback dim-qrFallback' }, + refreshing ? '二维码正在自动刷新…' : '二维码图片正在生成…')), + h('div', { className: 'ddt-countdown dim-countdown' }, + h('div', { className: 'ddt-countdownTop dim-countdownTop' }, + h('span', null, '当前二维码有效时间'), + h('strong', null, refreshing ? '--:--' : formatRemaining(remaining))), + h('div', { className: 'ddt-progress dim-progress', style: { '--ddt-progress': `${progress}%` } }, h('span')))), + h('div', { className: 'ddt-qrCopy dim-qrCopy' }, + h('div', { className: 'ddt-stateLabel dim-stateLabel' }, + h('span', { className: 'ddt-dot dim-stateDot', 'data-tone': 'warning' }), + h('span', null, refreshing ? '正在刷新二维码' : '等待企业微信 App 扫码')), + h('h3', null, '使用企业微信 App 完成智能机器人授权'), + h('p', null, '企业微信官方页面会创建一个智能机器人,并把连接凭据安全交给本机 Harness Host。'), + h('ol', { className: 'ddt-steps dim-steps' }, + h('li', null, '打开企业微信 App,扫描左侧二维码'), + h('li', null, '在腾讯授权页面确认创建智能机器人'), + h('li', null, '返回这里等待连接完成')), + h('div', { className: 'ddt-actions dim-viewActions' }, + h(Button, { onClick: onRefresh, disabled: busy }, '重新生成二维码'), + h(Button, { kind: 'quiet', onClick: onCancel, disabled: busy }, '取消'))))); +} + +function ProvisionView({ provision, busy, onRetry, onClose }) { + if (provision.status === 'connecting') { + return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-specialView', 'aria-busy': 'true' }, + h('div', { className: 'ddt-spinner dim-spinner' }), + h('h3', null, '企业微信已授权,正在连接机器人'), + h('p', null, '凭据正在写入本机,并启动企业微信 WebSocket 消息连接。')); + } + const error = provision.error ?? { code: 'WECOM_PROVISION_FAILED', message: '企业微信机器人没有绑定完成' }; + return h('div', { className: 'ddt-card dim-surfaceCard' }, + h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' }, + h('h3', null, '企业微信机器人没有绑定完成'), + h('p', null, error.message), + h('span', { className: 'ddt-errorCode' }, error.code), + h('div', { className: 'ddt-actions dim-viewActions' }, + h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'), + h(Button, { onClick: onClose, disabled: busy }, '关闭')))); +} + +function RemoveConfirmation({ account, busy, onConfirm, onCancel }) { + return h('div', { className: 'ddt-confirm dim-confirm', role: 'alertdialog' }, + h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`), + h('p', null, '这会停止消息连接,并删除本机保存的应用凭据、机器人配置及会话映射。企业微信平台中的机器人不会被自动删除。'), + h('div', { className: 'ddt-actions dim-viewActions' }, + h(Button, { onClick: onCancel, disabled: busy }, '保留机器人'), + h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入'))); +} + +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' ? '正在连接' : '连接未就绪'; + 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' }, + h('div', { className: 'ddt-accountIdentity dim-botIdentity' }, + h('div', { className: 'ddt-avatar dim-botAvatar dwecom-avatar', 'aria-hidden': 'true' }, h(WecomLogoGlyph, { size: 29 })), + h('div', { className: 'dim-botName' }, + h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))), + h('div', { className: 'ddt-health dim-botHealth' }, + h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), h('span', null, stateLabel))), + h('dl', { className: 'ddt-metrics dim-botMetrics' }, + 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), + 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) }, '移除接入')))), + removing ? h(RemoveConfirmation, { + account, busy: busy === 'delete', onConfirm: onConfirmRemove, onCancel: onCancelRemove, + }) : null); +} + +export function WecomSettingsTab({ rpcCall }) { + const [model, setModel] = React.useState({ phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null }); + const [provision, setProvision] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [busyByBot, setBusyByBot] = React.useState({}); + const [removeTarget, setRemoveTarget] = React.useState(null); + const [now, setNow] = React.useState(Date.now()); + const mounted = React.useRef(true); + const addButtonRef = React.useRef(null); + + React.useEffect(() => { + const disposeDingtalk = installDingtalkStyles(); + const disposeWecom = installWecomStyles(); + mounted.current = true; + return () => { + mounted.current = false; + disposeWecom(); + disposeDingtalk(); + }; + }, []); + + const invoke = React.useCallback(async (endpoint, payload = {}, signal) => { + if (typeof rpcCall !== 'function') throw new TypeError('企业微信设置页缺少 RPC 连接'); + return unwrapRpcResult(await rpcCall(endpoint, payload, signal)); + }, [rpcCall]); + + const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => { + if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null })); + try { + const snapshot = normalizeSnapshot(await invoke(WECOM_ENDPOINTS.status, {}, signal)); + if (!mounted.current || signal?.aborted) return undefined; + setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null }); + if (restore && snapshot.provisioning) setProvision({ + ...snapshot.provisioning, + durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()), + }); + return snapshot; + } catch (error) { + if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) { + setModel((current) => ({ ...current, phase: silent ? current.phase : 'error', error: presentError(error) })); + } + return undefined; + } + }, [invoke]); + + React.useEffect(() => { + const controller = new AbortController(); + void loadStatus({ signal: controller.signal, restore: true }); + return () => controller.abort(); + }, [loadStatus]); + + React.useEffect(() => { + if (model.phase !== 'ready') return undefined; + const controller = new AbortController(); + const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15_000); + return () => { controller.abort(); window.clearInterval(timer); }; + }, [loadStatus, model.phase]); + + React.useEffect(() => { + if (!provision || !ACTIVE_STATES.has(provision.status)) return undefined; + const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1_000); + return () => window.clearInterval(timer); + }, [provision?.attemptId, provision?.status]); + + const startProvisioning = React.useCallback(async (replace = false) => { + setBusy(true); + try { + if (replace && provision?.attemptId) await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + if (!mounted.current) return; + setProvision({ status: 'starting' }); + const started = normalizeProvisioning(await invoke(WECOM_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' })); + if (!mounted.current) return; + setNow(Date.now()); + setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) }); + } catch (error) { + if (mounted.current) setProvision({ status: 'failed', error: presentError(error) }); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke, provision?.attemptId]); + + const closeProvision = React.useCallback(async () => { + setBusy(true); + try { + if (provision?.attemptId && ACTIVE_STATES.has(provision.status)) { + await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + } + if (mounted.current) setProvision(null); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke, provision?.attemptId, provision?.status]); + + React.useEffect(() => { + const attemptId = provision?.attemptId; + if (!attemptId || !ACTIVE_STATES.has(provision.status)) return undefined; + const controller = new AbortController(); + let disposed = false; + let timer; + const poll = async () => { + try { + const current = normalizeProvisioning(await invoke(WECOM_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal)); + if (disposed || controller.signal.aborted || !mounted.current) return; + if (current.status === 'connected') { + setProvision(null); + await loadStatus({ signal: controller.signal, silent: true }); + return; + } + setProvision((previous) => previous?.attemptId === attemptId + ? { ...previous, ...current, durationMs: current.qrRevision !== previous.qrRevision + ? Math.max(1, current.expiresAt - Date.now()) : previous.durationMs } + : previous); + if (ACTIVE_STATES.has(current.status)) timer = window.setTimeout(poll, current.pollIntervalMs); + } catch (error) { + if (!disposed && !controller.signal.aborted && mounted.current) { + setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) })); + } + } + }; + timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000); + return () => { disposed = true; controller.abort(); window.clearTimeout(timer); }; + }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); + + const botAction = React.useCallback(async (account, operation, endpoint, payload) => { + setBusyByBot((current) => ({ ...current, [account.botId]: operation })); + try { + const snapshot = normalizeSnapshot(await invoke(endpoint, payload)); + if (mounted.current) setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null }); + return snapshot; + } finally { + if (mounted.current) setBusyByBot((current) => { + const next = { ...current }; delete next[account.botId]; return next; + }); + } + }, [invoke]); + + let provisionView = null; + if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请企业微信二维码…'); + else if (['pending', 'refreshing'].includes(provision?.status)) provisionView = h(QrPanel, { + provision, now, busy, onRefresh: () => void startProvisioning(true), onCancel: () => void closeProvision(), + }); + else if (provision) provisionView = h(ProvisionView, { + provision, busy, onRetry: () => void startProvisioning(true), onClose: () => void closeProvision(), + }); + + 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('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) => + h('li', { key: account.botId }, h(AccountCard, { + account, + busy: busyByBot[account.botId], + removing: removeTarget === account.botId, + onReconnect: () => void botAction(account, 'reconnect', WECOM_ENDPOINTS.reconnectBot, { botId: account.botId }), + onRequestRemove: () => setRemoveTarget(account.botId), + onCancelRemove: () => setRemoveTarget(null), + onConfirmRemove: async () => { + await botAction(account, 'delete', WECOM_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true }); + if (mounted.current) setRemoveTarget(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 }), + 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, + provisionView, + model.bots.length === 0 && !provision ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null, + botList)); +} + +export function apply(ctx) { + ctx.effect(() => installWecomStyles(), 'wecom-settings: install client styles'); + const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal); + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ + name: 'settings.plugins.tab', id: 'wecom', order: 45, label: '企业微信', inject: () => ({ rpcCall }), + }, WecomSettingsTab)); +} diff --git a/plugin-src/client/channels/wecom/styles.js b/plugin-src/client/channels/wecom/styles.js new file mode 100644 index 0000000..21f54f9 --- /dev/null +++ b/plugin-src/client/channels/wecom/styles.js @@ -0,0 +1,19 @@ +export const WECOM_STYLE_ID = 'xmanrui-dsh-im-wecom-settings'; + +const CSS = String.raw` +.dwecom-page { --ddt-accent: #3370ff; --ddt-accent-deep: #245bdb; --ddt-accent-wash: #eef4ff; } +.dwecom-avatar, .dwecom-brand { color: #3370ff; background: #fff; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); } +.dwecom-avatar svg, .dwecom-brand svg { display: block; } +`; + +export function installWecomStyles() { + if (typeof document === 'undefined') return () => {}; + const existing = document.querySelector(`style[data-plugin-css="${WECOM_STYLE_ID}"]`); + if (existing) return () => {}; + const style = document.createElement('style'); + style.dataset.plugin = '@xmanrui/dsh-im'; + style.dataset.pluginCss = WECOM_STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); + return () => style.remove(); +} diff --git a/plugin-src/client/index.js b/plugin-src/client/index.js index 4ec08a3..ecc0953 100644 --- a/plugin-src/client/index.js +++ b/plugin-src/client/index.js @@ -1,6 +1,12 @@ import * as React from 'react'; -import { DingtalkLogoGlyph, FeishuLogoGlyph, QqLogoGlyph, WeixinLogoGlyph } from './channel-logos.js'; +import { + DingtalkLogoGlyph, + FeishuLogoGlyph, + QqLogoGlyph, + WecomLogoGlyph, + WeixinLogoGlyph, +} from './channel-logos.js'; import { DINGTALK_RPC_CHANNEL } from './channels/dingtalk/api.js'; import { DingtalkSettingsTab } from './channels/dingtalk/index.js'; import { FeishuSettingsTab } from './channels/feishu/index.js'; @@ -9,6 +15,9 @@ import { installFeishuStyles } from './channels/feishu/styles.js'; import { QQ_RPC_CHANNEL } from './channels/qq/api.js'; import { QqSettingsTab } from './channels/qq/index.js'; import { installQqStyles } from './channels/qq/styles.js'; +import { WECOM_RPC_CHANNEL } from './channels/wecom/api.js'; +import { WecomSettingsTab } from './channels/wecom/index.js'; +import { installWecomStyles } from './channels/wecom/styles.js'; import { WeixinSettingsTab } from './channels/weixin/index.js'; import { WEIXIN_RPC_CHANNEL } from './channels/weixin/api.js'; import { installWeixinStyles } from './channels/weixin/styles.js'; @@ -23,6 +32,7 @@ const CHANNELS = Object.freeze([ { id: 'weixin', label: '微信' }, { id: 'feishu', label: '飞书' }, { id: 'dingtalk', label: '钉钉' }, + { id: 'wecom', label: '企业微信' }, { id: 'qq', label: 'QQ' }, ]); @@ -45,14 +55,19 @@ function QqLogo() { return h('span', { className: 'dim-logo dim-logoQq', 'aria-hidden': 'true' }, h(QqLogoGlyph)); } +function WecomLogo() { + return h('span', { className: 'dim-logo dim-logoWecom', 'aria-hidden': 'true' }, h(WecomLogoGlyph)); +} + function ChannelLogo({ channel }) { if (channel === 'weixin') return h(WeixinLogo); if (channel === 'feishu') return h(FeishuLogo); if (channel === 'dingtalk') return h(DingtalkLogo); + if (channel === 'wecom') return h(WecomLogo); return h(QqLogo); } -export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }) { +export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }) { const [selected, setSelected] = React.useState('weixin'); const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0]; return h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' }, @@ -87,14 +102,22 @@ export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixi ? h(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === 'dingtalk' ? h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) - : h(QqSettingsTab, { rpcCall: qqRpcCall })), + : active.id === 'wecom' + ? h(WecomSettingsTab, { rpcCall: wecomRpcCall }) + : h(QqSettingsTab, { rpcCall: qqRpcCall })), ), ); } export function apply(ctx) { ctx.effect(() => { - const disposers = [installFeishuStyles(), installWeixinStyles(), installQqStyles(), installImStyles()]; + const disposers = [ + installFeishuStyles(), + installWeixinStyles(), + installWecomStyles(), + installQqStyles(), + installImStyles(), + ]; return () => { for (const dispose of disposers.reverse()) dispose(); }; @@ -108,12 +131,14 @@ export function apply(ctx) { ctx.connection.rpc.call(DINGTALK_RPC_CHANNEL, endpoint, payload, signal); const qqRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal); + const wecomRpcCall = (endpoint, payload, signal) => + ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal); ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ name: 'settings.plugins.tab', id: 'im', order: 20, label: 'IM机器人', - inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }), + inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }), }, IMSettingsTab)); } diff --git a/plugin-src/client/styles.js b/plugin-src/client/styles.js index 75762b5..ca9671c 100644 --- a/plugin-src/client/styles.js +++ b/plugin-src/client/styles.js @@ -31,11 +31,13 @@ const CSS = String.raw` .dim-logoDingtalk svg { width: 24px; height: 24px; } .dim-logoQq { color: white; background: #1677ff; } .dim-logoQq svg { width: 21px; height: 21px; } +.dim-logoWecom { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); } +.dim-logoWecom svg { width: 22px; height: 22px; } .dim-channelCopy { min-width: 0; display: block; } .dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; } .dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-line-divider, #eef0f3); } .dim-panel { min-width: 0; container-type: inline-size; } -.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page { width: 100%; max-width: none; padding: 0 0 24px; } +.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; } diff --git a/plugin-src/host/channels/wecom/connection-supervisor.mjs b/plugin-src/host/channels/wecom/connection-supervisor.mjs new file mode 100644 index 0000000..d545593 --- /dev/null +++ b/plugin-src/host/channels/wecom/connection-supervisor.mjs @@ -0,0 +1,124 @@ +const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]); + +function retryDelays(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS]; + const valid = value.filter((delay) => Number.isFinite(delay) && delay >= 0); + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS]; +} + +export class ConnectionSupervisor { + #controller; + #harness; + #logger; + #retryDelays; + #healthyIntervalMs; + #setTimeout; + #clearTimeout; + #timer = null; + #running = null; + #retryIndex = 0; + #closed = false; + #started = false; + #ready; + #resolveReady; + + constructor({ + controller, + harness, + logger = console, + retryDelaysMs, + healthyIntervalMs = 15_000, + setTimeoutImpl = setTimeout, + clearTimeoutImpl = clearTimeout, + }) { + if (!controller || typeof controller.initialize !== 'function' || typeof controller.status !== 'function') { + throw new TypeError('ConnectionSupervisor requires a controller'); + } + if (!harness || typeof harness.ensureRunning !== 'function') { + throw new TypeError('ConnectionSupervisor requires a Harness client'); + } + this.#controller = controller; + this.#harness = harness; + this.#logger = logger; + this.#retryDelays = retryDelays(retryDelaysMs); + this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 + ? healthyIntervalMs : 15_000; + this.#setTimeout = setTimeoutImpl; + this.#clearTimeout = clearTimeoutImpl; + this.#ready = new Promise((resolve) => { + this.#resolveReady = resolve; + }); + } + + get ready() { + return this.#ready; + } + + start() { + if (this.#started || this.#closed) return this; + this.#started = true; + this.#schedule(0); + return this; + } + + async close() { + if (this.#closed) return; + this.#closed = true; + if (this.#timer !== null) this.#clearTimeout(this.#timer); + this.#timer = null; + await this.#running?.catch(() => undefined); + this.#resolveReady?.(null); + this.#resolveReady = null; + } + + #schedule(delayMs) { + if (this.#closed) return; + this.#timer = this.#setTimeout(() => { + this.#timer = null; + void this.#run(); + }, delayMs); + this.#timer?.unref?.(); + } + + async #run() { + if (this.#closed || this.#running) return; + const operation = this.#reconcile(); + this.#running = operation; + try { + await operation; + } finally { + if (this.#running === operation) this.#running = null; + } + } + + async #reconcile() { + try { + await this.#harness.ensureRunning(); + if (this.#closed) return; + const status = await this.#controller.initialize(); + if (this.#closed) return; + this.#resolveReady?.(status); + this.#resolveReady = null; + const { configured, connected } = status.totals; + if (connected < configured) { + const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.(`[dsh-im:wecom] ${connected}/${configured} bots connected; retrying in ${delay}ms`); + this.#schedule(delay); + return; + } + this.#retryIndex = 0; + this.#schedule(this.#healthyIntervalMs); + } catch (error) { + if (this.#closed) return; + const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.(`[dsh-im:wecom] connection reconciliation failed; retrying in ${delay}ms`, error); + this.#schedule(delay); + } + } +} + +export function createConnectionSupervisor(options) { + return new ConnectionSupervisor(options); +} diff --git a/plugin-src/host/channels/wecom/index.mjs b/plugin-src/host/channels/wecom/index.mjs new file mode 100644 index 0000000..80c71e2 --- /dev/null +++ b/plugin-src/host/channels/wecom/index.mjs @@ -0,0 +1,29 @@ +import { createProductionController } from './production.mjs'; +import { installWecomRpc } from './rpc.mjs'; + +export const name = 'dsh-im-wecom-host'; +export const inject = ['connection', 'credentials', 'webServer']; + +export async function apply(ctx, config = {}) { + if (config?.controller) return installWecomRpc(ctx, config.controller, config.rpcOptions); + const production = await createProductionController(ctx, config, config.internals); + const disposeRpc = installWecomRpc(ctx, production.controller, config.rpcOptions); + ctx.effect(() => async () => production.close(), 'dsh-im: close Enterprise WeChat bot connections'); + return disposeRpc; +} + +export function createWecomHostPlugin(config) { + return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) }); +} + +export { createConnectionSupervisor, ConnectionSupervisor } from './connection-supervisor.mjs'; +export { createProductionController } from './production.mjs'; +export { + WECOM_ENDPOINTS, + WECOM_RPC_CHANNEL, + WECOM_RPC_ENDPOINTS, + createWecomRpcHandler, + installWecomRpc, +} from './rpc.mjs'; +export { WecomController } from '../../../../src/channels/wecom/wecom-controller.mjs'; +export { WecomRuntime } from '../../../../src/channels/wecom/wecom-runtime.mjs'; diff --git a/plugin-src/host/channels/wecom/production.mjs b/plugin-src/host/channels/wecom/production.mjs new file mode 100644 index 0000000..aeb1c03 --- /dev/null +++ b/plugin-src/host/channels/wecom/production.mjs @@ -0,0 +1,113 @@ +import { unlink } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { WecomConfigStore } from '../../../../src/channels/wecom/config-store.mjs'; +import { WecomHarnessClient } from '../../../../src/channels/wecom/harness-client.mjs'; +import { WecomQrAuth } from '../../../../src/channels/wecom/qr-auth.mjs'; +import { WecomStateStore } from '../../../../src/channels/wecom/state-store.mjs'; +import { WecomController } from '../../../../src/channels/wecom/wecom-controller.mjs'; +import { WecomRuntime } from '../../../../src/channels/wecom/wecom-runtime.mjs'; +import { createConnectionSupervisor } from './connection-supervisor.mjs'; + +function harnessOrigin(webServer, configured) { + if (configured !== undefined) return new URL(configured); + const port = webServer?.port; + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error('dsh-im Enterprise WeChat requires an initialized DSH webServer port'); + } + return new URL(`http://127.0.0.1:${port}`); +} + +function pluginPaths(config) { + const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')); + const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-wecom')); + return { + config: resolve(config.configPath ?? join(root, 'config.json')), + bots: resolve(config.botsDir ?? join(root, 'bots')), + }; +} + +export async function createProductionController(ctx, config = {}, internals = {}) { + if (!ctx?.credentials) throw new TypeError('dsh-im Enterprise WeChat requires ctx.credentials'); + if (!ctx?.webServer) throw new TypeError('dsh-im Enterprise WeChat requires ctx.webServer'); + + const ConfigStore = internals.ConfigStore ?? WecomConfigStore; + const StateStore = internals.StateStore ?? WecomStateStore; + const Harness = internals.HarnessClient ?? WecomHarnessClient; + const Controller = internals.Controller ?? WecomController; + const Runtime = internals.Runtime ?? WecomRuntime; + const QrAuth = internals.QrAuth ?? WecomQrAuth; + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor; + const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:wecom') : (ctx.logger ?? console); + const paths = pluginPaths(config); + const configStore = await new ConfigStore(paths.config).load(); + const qrAuth = internals.qrAuth ?? new QrAuth({ + source: config.qrSource ?? 'deepseek-harness', + platform: config.qrPlatform, + }); + const stateStores = new Map(); + const statePath = (botId) => resolve(paths.bots, botId, 'state.json'); + const stateFor = async (botId) => { + let state = stateStores.get(botId); + if (!state) { + state = await new StateStore(statePath(botId)).load(); + stateStores.set(botId, state); + } + return state; + }; + const harness = new Harness({ + baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), + workspace: resolve(config.workspace ?? process.cwd()), + agentPreset: config.agentPreset ?? 'standard', + autostart: false, + dshBin: config.dshBin ?? 'dsh', + }); + const controller = new Controller({ + qrAuth, + credentials: ctx.credentials, + configStore, + logger, + createRuntime: async ({ botId, config: botConfig, secret }) => new Runtime({ + config: botConfig, + secret, + harness, + state: await stateFor(botId), + replyTimeoutMs: config.replyTimeoutMs ?? 600_000, + connectTimeoutMs: config.connectTimeoutMs ?? 20_000, + maxReconnectAttempts: config.maxReconnectAttempts ?? 10, + logger: { + error: (...args) => logger.error?.(`[${botId}]`, ...args), + warn: (...args) => logger.warn?.(`[${botId}]`, ...args), + info: (...args) => logger.info?.(`[${botId}]`, ...args), + debug: (...args) => logger.debug?.(`[${botId}]`, ...args), + }, + }), + deleteState: async ({ botId }) => { + const state = stateStores.get(botId); + stateStores.delete(botId); + if (state && typeof state.remove === 'function') return state.remove(); + try { + await unlink(statePath(botId)); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + }, + }); + const supervisor = createSupervisor({ + controller, + harness, + logger, + retryDelaysMs: config.retryDelaysMs, + healthyIntervalMs: config.healthyIntervalMs, + }).start(); + return { + controller, + ready: supervisor.ready, + async close() { + await supervisor.close(); + await controller.close(); + harness.stopManagedProcess(); + }, + }; +} diff --git a/plugin-src/host/channels/wecom/rpc.mjs b/plugin-src/host/channels/wecom/rpc.mjs new file mode 100644 index 0000000..b0d24dd --- /dev/null +++ b/plugin-src/host/channels/wecom/rpc.mjs @@ -0,0 +1,137 @@ +import QRCode from 'qrcode'; + +export const WECOM_RPC_CHANNEL = '/wecom'; +export const WECOM_ENDPOINTS = Object.freeze({ + status: 'connection.status', + beginProvisioning: 'provision.begin', + pollProvisioning: 'provision.poll', + cancelProvisioning: 'provision.cancel', + reconnectBot: 'bot.reconnect', + deleteBot: 'bot.delete', +}); +export const WECOM_RPC_ENDPOINTS = Object.freeze(Object.values(WECOM_ENDPOINTS)); + +const FORBIDDEN_PUBLIC_KEYS = new Set([ + 'secret', 'secretRef', 'scode', 'remoteBotId', 'verificationUrl', 'bot_info', 'botid', +]); + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function exactKeys(value, allowed) { + return isRecord(value) && Object.keys(value).every((key) => allowed.includes(key)); +} + +function validId(value) { + return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value); +} + +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.'; + if (endpoint === WECOM_ENDPOINTS.beginProvisioning) { + return exactKeys(payload, ['locale']) && (payload.locale === undefined || payload.locale === 'zh-CN') + ? null : 'provision.begin received unsupported fields.'; + } + if ([WECOM_ENDPOINTS.pollProvisioning, WECOM_ENDPOINTS.cancelProvisioning].includes(endpoint)) { + return exactKeys(payload, ['attemptId']) && validId(payload.attemptId) + ? null : `${endpoint} requires an attemptId.`; + } + if (endpoint === WECOM_ENDPOINTS.reconnectBot) { + return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.'; + } + if (endpoint === WECOM_ENDPOINTS.deleteBot) { + return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true + ? null : 'bot.delete requires a botId and confirm=true.'; + } + return 'Unknown Enterprise WeChat endpoint.'; +} + +function sanitizePublic(value) { + if (Array.isArray(value)) return value.map(sanitizePublic); + if (!isRecord(value)) return value; + const safe = {}; + for (const [key, child] of Object.entries(value)) { + if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child); + } + return safe; +} + +async function qrDataUrl(value) { + return QRCode.toDataURL(value, { + type: 'image/png', errorCorrectionLevel: 'M', margin: 2, width: 320, + }); +} + +async function withEncodedQr(value, encodeQr) { + if (!value || typeof value.verificationUrl !== 'string') return sanitizePublic(value); + return sanitizePublic({ ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) }); +} + +async function publicStatus(status, encodeQr) { + const value = structuredClone(status); + if (value?.provisioning) value.provisioning = await withEncodedQr(value.provisioning, encodeQr); + return sanitizePublic(value); +} + +export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { + for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) { + if (typeof controller?.[method] !== 'function') { + throw new TypeError(`A complete Enterprise WeChat controller is required (${method})`); + } + } + const qrCache = new Map(); + const cachedEncode = (url) => { + let encoded = qrCache.get(url); + if (!encoded) { + if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value); + encoded = Promise.resolve().then(() => encodeQr(url)); + qrCache.set(url, encoded); + } + return encoded; + }; + return async (endpoint, payload, signal) => { + if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }; + if (!WECOM_RPC_ENDPOINTS.includes(endpoint)) { + return { ok: false, error: { code: 'bad-request', message: 'Unknown Enterprise WeChat endpoint.' } }; + } + const invalid = payloadFailure(endpoint, payload); + if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } }; + try { + let value; + if (endpoint === WECOM_ENDPOINTS.status) value = await publicStatus(await controller.status(), cachedEncode); + else if (endpoint === WECOM_ENDPOINTS.beginProvisioning) { + value = await withEncodedQr(await controller.startProvisioning(), cachedEncode); + } else if (endpoint === WECOM_ENDPOINTS.pollProvisioning) { + const current = await controller.registrationStatus(payload.attemptId); + if (!current) return { ok: false, error: { code: 'bad-request', message: 'The provisioning attempt no longer exists.' } }; + value = await withEncodedQr(current, cachedEncode); + } else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) { + value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId)); + } else if (endpoint === WECOM_ENDPOINTS.reconnectBot) { + value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode); + } else { + value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode); + } + return signal?.aborted + ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } } + : { ok: true, value }; + } catch { + return signal?.aborted + ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } } + : { ok: false, error: { code: 'wecom-operation-failed', message: '企业微信操作失败,请稍后重试。' } }; + } + }; +} + +export function installWecomRpc(ctx, controller, options) { + if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') { + throw new TypeError('DSH Host Connection RPC is required'); + } + return ctx.connection.rpc.handle( + WECOM_RPC_CHANNEL, + createWecomRpcHandler(controller, options), + { authority: 'loopback' }, + ); +} diff --git a/plugin-src/host/index.mjs b/plugin-src/host/index.mjs index 5533ea3..247f623 100644 --- a/plugin-src/host/index.mjs +++ b/plugin-src/host/index.mjs @@ -1,6 +1,7 @@ import { apply as applyDingtalk } from './channels/dingtalk/index.mjs'; import { apply as applyFeishu } from './channels/feishu/index.mjs'; import { apply as applyQq } from './channels/qq/index.mjs'; +import { apply as applyWecom } from './channels/wecom/index.mjs'; import { apply as applyWeixin } from './channels/weixin/index.mjs'; export const name = 'dsh-im-host'; @@ -10,6 +11,7 @@ export function createImHostPlugin(internals = {}) { const startFeishu = internals.applyFeishu ?? applyFeishu; const startWeixin = internals.applyWeixin ?? applyWeixin; const startDingtalk = internals.applyDingtalk ?? applyDingtalk; + const startWecom = internals.applyWecom ?? applyWecom; const startQq = internals.applyQq ?? applyQq; return Object.freeze({ name, @@ -18,6 +20,7 @@ export function createImHostPlugin(internals = {}) { await startFeishu(ctx, config.feishu ?? {}); await startWeixin(ctx, config.weixin ?? {}); await startDingtalk(ctx, config.dingtalk ?? {}); + await startWecom(ctx, config.wecom ?? {}); await startQq(ctx, config.qq ?? {}); }, }); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 2503e3e..c379f13 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -14,10 +14,12 @@ const required = [ 'plugin-src/host/channels/weixin/index.mjs', 'plugin-src/host/channels/dingtalk/index.mjs', 'plugin-src/host/channels/qq/index.mjs', + 'plugin-src/host/channels/wecom/index.mjs', 'src/channels/feishu/feishu-runtime.mjs', 'src/channels/weixin/weixin-runtime.mjs', 'src/channels/dingtalk/dingtalk-runtime.mjs', 'src/channels/qq/qq-runtime.mjs', + 'src/channels/wecom/wecom-runtime.mjs', ]; await Promise.all(required.map((path) => access(resolve(root, path)))); @@ -49,7 +51,7 @@ if (!client.includes('container-type: inline-size') || !client.includes('@container (max-width: 680px)')) { throw new Error('client bundle does not contain the narrow-panel DingTalk QR layout'); } -for (const marker of ['/feishu', '/weixin', '/dingtalk', '/qq']) { +for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq']) { if (!host.includes(marker)) { throw new Error(`host bundle does not contain the internal ${marker} RPC provider`); } @@ -75,6 +77,7 @@ const directDependencies = { 'dingtalk-stream': '2.1.4', '@tencent-connect/qqbot-connector': '1.2.0', '@tencent-connect/qqbot-nodejs': '1.0.4', + '@wecom/aibot-node-sdk': '1.0.7', qrcode: '1.5.4', }; for (const [name, version] of Object.entries(directDependencies)) { diff --git a/src/channels/wecom/config-store.mjs b/src/channels/wecom/config-store.mjs new file mode 100644 index 0000000..66ffa33 --- /dev/null +++ b/src/channels/wecom/config-store.mjs @@ -0,0 +1,170 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) }); + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function safeIntegrationId(value) { + const id = cleanString(value); + return id && /^wecom_[a-f0-9]{24}$/.test(id) ? id : null; +} + +function safeSecretRef(value) { + const ref = cleanString(value); + return ref && /^DSH_WECOM_BOT_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null; +} + +export function deriveWecomBotIdentity(remoteBotId) { + const raw = cleanString(remoteBotId); + if (!raw) throw new TypeError('Enterprise WeChat bot ID is required'); + const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24); + return { + botId: `wecom_${digest}`, + secretRef: `DSH_WECOM_BOT_SECRET_${digest.toUpperCase()}`, + }; +} + +export function maskWecomBotId(remoteBotId) { + const value = cleanString(remoteBotId) ?? ''; + if (!value) return '企业微信机器人'; + if (value.length <= 10) return `${value.slice(0, 3)}•••`; + return `${value.slice(0, 6)}••••${value.slice(-4)}`; +} + +function normalizeBot(value) { + if (!value || typeof value !== 'object') return null; + const botId = safeIntegrationId(value.botId); + const remoteBotId = cleanString(value.remoteBotId); + const secretRef = safeSecretRef(value.secretRef); + if (!botId || !remoteBotId || !secretRef) return null; + const derived = deriveWecomBotIdentity(remoteBotId); + if (derived.botId !== botId || derived.secretRef !== secretRef) return null; + return Object.freeze({ + botId, + remoteBotId, + secretRef, + createdAt: cleanString(value.createdAt) ?? new Date().toISOString(), + connectedAt: cleanString(value.connectedAt), + }); +} + +function normalizeDocument(value) { + if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null; + const bots = value.bots.map(normalizeBot); + if (bots.some((bot) => bot === null)) return null; + const ids = new Set(); + const remoteIds = new Set(); + const refs = new Set(); + for (const bot of bots) { + if (ids.has(bot.botId) || remoteIds.has(bot.remoteBotId) || refs.has(bot.secretRef)) return null; + ids.add(bot.botId); + remoteIds.add(bot.remoteBotId); + refs.add(bot.secretRef); + } + return Object.freeze({ version: 1, bots: Object.freeze(bots) }); +} + +export class WecomConfigStore { + #path; + #value = EMPTY_DOCUMENT; + #writeQueue = Promise.resolve(); + + constructor(path) { + this.#path = path; + } + + async load() { + try { + const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8'))); + if (!normalized) throw new Error('dsh-im Enterprise WeChat config contains invalid bot data'); + this.#value = normalized; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + this.#value = EMPTY_DOCUMENT; + } + return this; + } + + list() { + return structuredClone(this.#value.bots); + } + + get(botId) { + const bot = this.#value.bots.find((candidate) => candidate.botId === botId); + return bot ? structuredClone(bot) : null; + } + + getByRemoteBotId(remoteBotId) { + const bot = this.#value.bots.find((candidate) => candidate.remoteBotId === remoteBotId); + return bot ? structuredClone(bot) : null; + } + + async save(value) { + const normalized = normalizeBot(value); + if (!normalized) throw new Error('Refusing to persist incomplete Enterprise WeChat bot data'); + return this.#mutate((bots) => { + const remoteCollision = bots.find( + (bot) => bot.remoteBotId === normalized.remoteBotId && bot.botId !== normalized.botId, + ); + const refCollision = bots.find( + (bot) => bot.secretRef === normalized.secretRef && bot.botId !== normalized.botId, + ); + if (remoteCollision || refCollision) throw new Error('Duplicate Enterprise WeChat bot identity'); + const index = bots.findIndex((bot) => bot.botId === normalized.botId); + if (index === -1) bots.push(normalized); + else bots[index] = normalized; + return structuredClone(normalized); + }); + } + + async remove(botId) { + if (!safeIntegrationId(botId)) throw new TypeError('Invalid Enterprise WeChat bot ID'); + return this.#mutate((bots) => { + const index = bots.findIndex((bot) => bot.botId === botId); + if (index === -1) return null; + const [removed] = bots.splice(index, 1); + return structuredClone(removed); + }); + } + + async clear() { + const operation = this.#writeQueue.then(async () => { + try { + await unlink(this.#path); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + this.#value = EMPTY_DOCUMENT; + }); + this.#writeQueue = operation.then(() => undefined, () => undefined); + await operation; + } + + async #mutate(mutator) { + let result; + const operation = this.#writeQueue.then(async () => { + const bots = [...this.#value.bots]; + result = mutator(bots); + const document = Object.freeze({ version: 1, bots: Object.freeze(bots) }); + await this.#write(document); + this.#value = document; + }); + this.#writeQueue = operation.then(() => undefined, () => undefined); + await operation; + return result; + } + + async #write(document) { + await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 }); + const temporary = `${this.#path}.tmp`; + await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await rename(temporary, this.#path); + } +} diff --git a/src/channels/wecom/harness-client.mjs b/src/channels/wecom/harness-client.mjs new file mode 100644 index 0000000..a5086c5 --- /dev/null +++ b/src/channels/wecom/harness-client.mjs @@ -0,0 +1,3 @@ +import { HarnessClient } from '../weixin/harness-client.mjs'; + +export class WecomHarnessClient extends HarnessClient {} diff --git a/src/channels/wecom/qr-auth.mjs b/src/channels/wecom/qr-auth.mjs new file mode 100644 index 0000000..e78bdef --- /dev/null +++ b/src/channels/wecom/qr-auth.mjs @@ -0,0 +1,95 @@ +const GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate'; +const POLL_URL = 'https://work.weixin.qq.com/ai/qc/query_result'; +const QR_TTL_MS = 5 * 60_000; +const POLL_INTERVAL_MS = 3_000; + +function defaultPlatform() { + if (process.platform === 'win32') return 2; + if (process.platform === 'linux') return 3; + return 1; +} + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function safeVerificationUrl(value) { + const raw = cleanString(value); + if (!raw) return null; + try { + const url = new URL(raw); + return url.protocol === 'https:' && url.hostname === 'work.weixin.qq.com' && (!url.port || url.port === '443') + ? url.href : null; + } catch { + return null; + } +} + +function combinedSignal(signal, timeoutMs) { + return signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs); +} + +async function requestJson(fetchImpl, url, signal) { + const response = await fetchImpl(url, { + method: 'GET', + redirect: 'error', + signal: combinedSignal(signal, 10_000), + headers: { accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`Enterprise WeChat QR service returned HTTP ${response.status}`); + return response.json(); +} + +export class WecomQrAuth { + #fetch; + #clock; + #source; + #platform; + + constructor({ + fetch: fetchImpl = globalThis.fetch, + clock = () => Date.now(), + source = 'deepseek-harness', + platform = defaultPlatform(), + } = {}) { + if (typeof fetchImpl !== 'function') throw new TypeError('A fetch implementation is required'); + this.#fetch = fetchImpl; + this.#clock = clock; + this.#source = source; + this.#platform = [1, 2, 3].includes(platform) ? platform : defaultPlatform(); + } + + async start({ signal } = {}) { + const url = new URL(GENERATE_URL); + url.searchParams.set('source', this.#source); + url.searchParams.set('plat', String(this.#platform)); + const body = await requestJson(this.#fetch, url, signal); + const scode = cleanString(body?.data?.scode); + const verificationUrl = safeVerificationUrl(body?.data?.auth_url); + if (!scode || !verificationUrl) throw new Error('Enterprise WeChat QR service returned invalid data'); + return { + scode, + verificationUrl, + expiresAt: this.#clock() + QR_TTL_MS, + pollIntervalMs: POLL_INTERVAL_MS, + }; + } + + async poll({ scode, signal } = {}) { + const code = cleanString(scode); + if (!code) throw new TypeError('Enterprise WeChat QR poll code is required'); + const url = new URL(POLL_URL); + url.searchParams.set('scode', code); + const body = await requestJson(this.#fetch, url, signal); + const state = cleanString(body?.data?.status)?.toLowerCase(); + if (state === 'success') { + const remoteBotId = cleanString(body?.data?.bot_info?.botid); + const secret = cleanString(body?.data?.bot_info?.secret); + if (!remoteBotId || !secret) throw new Error('Enterprise WeChat QR result omitted bot credentials'); + return { status: 'success', remoteBotId, secret }; + } + if (['expired', 'timeout'].includes(state)) return { status: 'expired' }; + if (['fail', 'failed', 'error'].includes(state)) return { status: 'failed' }; + return { status: 'waiting' }; + } +} diff --git a/src/channels/wecom/state-store.mjs b/src/channels/wecom/state-store.mjs new file mode 100644 index 0000000..8c7da78 --- /dev/null +++ b/src/channels/wecom/state-store.mjs @@ -0,0 +1,90 @@ +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); + +function normalizeState(value) { + if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE); + const sessions = {}; + if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) { + for (const [key, sessionId] of Object.entries(value.sessions)) { + if (typeof key === 'string' && typeof sessionId === 'string' && sessionId) sessions[key] = sessionId; + } + } + return { + version: 1, + sessions, + seenMessageIds: Array.isArray(value.seenMessageIds) + ? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000) + : [], + }; +} + +export class WecomStateStore { + #path; + #state = structuredClone(EMPTY_STATE); + #writeQueue = Promise.resolve(); + + constructor(path) { + this.#path = path; + } + + async load() { + try { + this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8'))); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + this.#state = structuredClone(EMPTY_STATE); + await this.#persist(); + } + return this; + } + + sessionFor(key) { + return this.#state.sessions[key] ?? null; + } + + async setSession(key, sessionId) { + this.#state.sessions[key] = sessionId; + await this.#persist(); + } + + async clearSession(key) { + delete this.#state.sessions[key]; + await this.#persist(); + } + + hasSeen(messageId) { + return this.#state.seenMessageIds.includes(messageId); + } + + async markSeen(messageId) { + if (this.hasSeen(messageId)) return; + this.#state.seenMessageIds.push(messageId); + if (this.#state.seenMessageIds.length > 1_000) { + this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000); + } + await this.#persist(); + } + + async remove() { + try { + await unlink(this.#path); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + this.#state = structuredClone(EMPTY_STATE); + } + + async #persist() { + const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`; + const operation = this.#writeQueue.then(async () => { + await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 }); + const temporary = `${this.#path}.tmp`; + await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 }); + await rename(temporary, this.#path); + }); + this.#writeQueue = operation.then(() => undefined, () => undefined); + await operation; + } +} diff --git a/src/channels/wecom/wecom-bridge.mjs b/src/channels/wecom/wecom-bridge.mjs new file mode 100644 index 0000000..af7bcf5 --- /dev/null +++ b/src/channels/wecom/wecom-bridge.mjs @@ -0,0 +1,243 @@ +import { generateReqId } from '@wecom/aibot-node-sdk'; + +const HELP_TEXT = [ + '企业微信机器人已连接 DeepSeek Harness。', + '', + '直接发送文字即可继续当前会话。', + '/new 开启一个全新会话', + '/status 检查连接状态', + '/help 显示本帮助', +].join('\n'); +const MAX_REPLY_BYTES = 18_000; + +function bodyOf(frame) { + return frame?.body && typeof frame.body === 'object' ? frame.body : {}; +} + +function conversationKey(frame) { + const body = bodyOf(frame); + return body.chattype === 'group' ? `group:${body.chatid}` : `direct:${body.from?.userid}`; +} + +function messageText(frame) { + const body = bodyOf(frame); + if (body.msgtype === 'text') return typeof body.text?.content === 'string' ? body.text.content.trim() : ''; + if (body.msgtype === 'voice') return typeof body.voice?.content === 'string' ? body.voice.content.trim() : ''; + if (body.msgtype === 'mixed' && Array.isArray(body.mixed?.msg_item)) { + return body.mixed.msg_item + .filter((item) => item?.msgtype === 'text' && typeof item.text?.content === 'string') + .map((item) => item.text.content) + .join('\n') + .trim(); + } + return ''; +} + +function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) { + const source = String(text ?? '').trim(); + if (!source) return []; + const chunks = []; + let current = ''; + let bytes = 0; + for (const character of source) { + const size = Buffer.byteLength(character); + if (current && bytes + size > maxBytes) { + chunks.push(current); + current = character; + bytes = size; + } else { + current += character; + bytes += size; + } + } + if (current) chunks.push(current); + return chunks; +} + +function progressText(update) { + if (update?.type === 'text') return update.text; + if (update?.type === 'tool') return `正在使用${update.name}…`; + return update?.text; +} + +export function createWecomBridgeStatus() { + return { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null, + }; +} + +export class WecomHarnessBridge { + #client; + #harness; + #state; + #status; + #logger; + #replyTimeoutMs; + #generateReqId; + #queues = new Map(); + + constructor({ + client, + harness, + state, + status = createWecomBridgeStatus(), + logger = console, + replyTimeoutMs = 600_000, + generateStreamId = generateReqId, + }) { + if (!client || typeof client.replyStream !== 'function' || typeof client.sendMessage !== 'function') { + throw new TypeError('Enterprise WeChat client is required'); + } + if (!harness || !state) throw new TypeError('Harness client and state store are required'); + this.#client = client; + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#generateReqId = generateStreamId; + } + + get status() { + return structuredClone(this.#status); + } + + accept(frame) { + const key = conversationKey(frame); + const previous = this.#queues.get(key) ?? Promise.resolve(); + const current = previous + .catch(() => undefined) + .then(() => this.#process(frame)) + .finally(() => { + if (this.#queues.get(key) === current) this.#queues.delete(key); + }); + this.#queues.set(key, current); + return current; + } + + async waitForIdle() { + await Promise.allSettled([...this.#queues.values()]); + } + + async #sendActive(chatId, text) { + for (const chunk of splitUtf8(text)) { + await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } }); + } + } + + async #sendImmediate(frame, chatId, text) { + const chunks = splitUtf8(text); + if (chunks.length === 0) return; + try { + await this.#client.replyStream(frame, this.#generateReqId('stream'), chunks[0], true); + for (const chunk of chunks.slice(1)) { + await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } }); + } + } catch { + await this.#sendActive(chatId, text); + } + } + + async #process(frame) { + const body = bodyOf(frame); + const messageId = typeof body.msgid === 'string' ? body.msgid : ''; + const senderId = typeof body.from?.userid === 'string' ? body.from.userid : ''; + const chatId = body.chattype === 'group' ? body.chatid : senderId; + if (!messageId || !senderId || !chatId || !['single', 'group'].includes(body.chattype)) return; + if (this.#state.hasSeen(messageId)) return; + + this.#status.messagesReceived += 1; + this.#status.lastMessageAt = new Date().toISOString(); + const text = messageText(frame); + let streamId = null; + let streamStarted = false; + try { + if (!text) { + await this.#sendImmediate(frame, chatId, '目前支持文字、语音转写和图文混排中的文字消息。'); + await this.#state.markSeen(messageId); + return; + } + const command = text.toLowerCase(); + if (command === '/help') { + await this.#sendImmediate(frame, chatId, HELP_TEXT); + await this.#state.markSeen(messageId); + return; + } + if (command === '/status') { + await this.#harness.ensureRunning(); + await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。'); + await this.#state.markSeen(messageId); + return; + } + const key = conversationKey(frame); + if (command === '/new') { + await this.#state.clearSession(key); + await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。'); + await this.#state.markSeen(messageId); + return; + } + + let sessionId = this.#state.sessionFor(key); + if (!sessionId || !(await this.#harness.sessionExists(sessionId))) { + sessionId = await this.#harness.createSession(); + await this.#state.setSession(key, sessionId); + } + + streamId = this.#generateReqId('stream'); + try { + await this.#client.replyStream(frame, streamId, '正在思考中…', false); + streamStarted = true; + } catch (error) { + this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error); + } + + const answer = await this.#harness.ask(sessionId, text, { + timeoutMs: this.#replyTimeoutMs, + onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function' + ? async (update) => { + const progress = splitUtf8(progressText(update))[0]; + if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false); + } + : undefined, + }); + + const chunks = splitUtf8(answer || '任务已完成,但没有生成可显示的文本。'); + let finalSent = false; + if (streamStarted && chunks.length > 0) { + try { + await this.#client.replyStream(frame, streamId, chunks[0], true); + for (const chunk of chunks.slice(1)) { + await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } }); + } + finalSent = true; + } catch (error) { + this.#logger.warn?.('[dsh-im:wecom] stream finalization failed; using an active reply:', error); + } + } + if (!finalSent) await this.#sendActive(chatId, answer); + await this.#state.markSeen(messageId); + this.#status.messagesReplied += 1; + this.#status.lastReplyAt = new Date().toISOString(); + this.#status.lastError = null; + } catch (error) { + this.#status.lastError = error?.message ?? String(error); + this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message'); + try { + if (streamStarted && streamId) { + await this.#client.replyStream(frame, streamId, '消息处理失败,请稍后重试。', true); + } else { + await this.#sendImmediate(frame, chatId, '消息处理失败,请稍后重试。'); + } + await this.#state.markSeen(messageId); + } catch { + this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply'); + } + } + } +} diff --git a/src/channels/wecom/wecom-controller.mjs b/src/channels/wecom/wecom-controller.mjs new file mode 100644 index 0000000..b5917e5 --- /dev/null +++ b/src/channels/wecom/wecom-controller.mjs @@ -0,0 +1,427 @@ +import { randomUUID } from 'node:crypto'; + +import { deriveWecomBotIdentity, maskWecomBotId } from './config-store.mjs'; + +const ACTIVE_ATTEMPT_STATES = new Set(['pending', 'connecting']); +const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled', 'expired']); + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function safeError(code, message) { + return Object.freeze({ code, message }); +} + +function publicAttempt(record) { + if (!record) return null; + return { + attemptId: record.id, + status: record.state, + pollIntervalMs: record.pollIntervalMs, + qrRevision: record.qrRevision, + ...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}), + ...(record.expiresAt ? { expiresAt: record.expiresAt } : {}), + ...(record.botId ? { botId: record.botId } : {}), + ...(record.error ? { error: structuredClone(record.error) } : {}), + }; +} + +export class WecomController { + #qrAuth; + #credentials; + #configStore; + #createRuntime; + #deleteState; + #logger; + #runtimes = new Map(); + #errors = new Map(); + #attempts = new Map(); + #activeAttemptId = null; + #transitions = new Map(); + #revision = 0; + #closed = false; + + constructor({ + qrAuth, + credentials, + configStore, + createRuntime, + deleteState = async () => {}, + logger = console, + }) { + if (!qrAuth || typeof qrAuth.start !== 'function' || typeof qrAuth.poll !== 'function') { + throw new TypeError('Enterprise WeChat QR auth is required'); + } + if (!credentials || typeof credentials.resolve !== 'function' + || typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') { + throw new TypeError('WecomController requires the DSH credential provider'); + } + if (!configStore || typeof configStore.list !== 'function' + || typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') { + throw new TypeError('WecomController requires a config store'); + } + if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required'); + this.#qrAuth = qrAuth; + this.#credentials = credentials; + this.#configStore = configStore; + this.#createRuntime = createRuntime; + this.#deleteState = deleteState; + this.#logger = logger; + } + + async initialize() { + if (this.#closed) return this.status(); + for (const config of this.#configStore.list()) { + await this.#withBotTransition(config.botId, async () => { + const existing = this.#runtimes.get(config.botId)?.status; + if (this.#closed || existing?.ready || existing?.wecomConnectionState === 'connecting') return; + const secret = await this.#resolveSecret(config.secretRef); + if (!secret) { + this.#errors.set(config.botId, safeError('missing-secret', '企业微信机器人凭据缺失,请移除后重新扫码。')); + return; + } + try { + await this.#startRuntime(config, secret); + this.#errors.delete(config.botId); + } catch (error) { + this.#errors.set(config.botId, safeError('connection-failed', '企业微信连接未就绪,插件会自动重试。')); + this.#logger.warn?.(`[dsh-im:wecom] bot ${config.botId} failed to initialize`); + } finally { + this.#touch(); + } + }); + } + return this.status(); + } + + async startProvisioning() { + if (this.#closed) throw new Error('Enterprise WeChat controller is closed'); + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + const record = { + id: randomUUID(), + state: 'pending', + createdAt: Date.now(), + expiresAt: null, + pollIntervalMs: 3_000, + qrRevision: 1, + verificationUrl: null, + scode: null, + botId: null, + error: null, + controller: new AbortController(), + polling: null, + transition: null, + }; + this.#attempts.set(record.id, record); + this.#activeAttemptId = record.id; + this.#touch(); + try { + const started = await this.#qrAuth.start({ signal: record.controller.signal }); + record.scode = cleanString(started.scode); + record.verificationUrl = cleanString(started.verificationUrl); + record.expiresAt = Number(started.expiresAt); + record.pollIntervalMs = Math.min(10_000, Math.max(500, Number(started.pollIntervalMs) || 3_000)); + if (!record.scode || !record.verificationUrl || !Number.isFinite(record.expiresAt)) { + throw new Error('Enterprise WeChat QR auth returned incomplete data'); + } + this.#touch(); + return publicAttempt(record); + } catch (error) { + record.state = record.controller.signal.aborted ? 'cancelled' : 'failed'; + record.error = record.controller.signal.aborted + ? safeError('cancelled', '扫码绑定已取消。') + : safeError('qr-start-failed', '无法生成企业微信二维码,请稍后重试。'); + this.#finishAttempt(record); + throw error; + } + } + + async registrationStatus(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record || TERMINAL_ATTEMPT_STATES.has(record.state)) return publicAttempt(record); + if (record.state === 'connecting') { + await record.transition?.catch(() => undefined); + return publicAttempt(record); + } + if (Date.now() >= record.expiresAt) { + record.state = 'expired'; + record.error = safeError('expired', '企业微信二维码已过期,请重新生成。'); + record.controller.abort(); + this.#finishAttempt(record); + return publicAttempt(record); + } + if (!record.polling) { + const polling = this.#pollAttempt(record).finally(() => { + if (record.polling === polling) record.polling = null; + }); + record.polling = polling; + } + await record.polling.catch(() => undefined); + return publicAttempt(record); + } + + async cancelProvisioning(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record) return null; + if (!TERMINAL_ATTEMPT_STATES.has(record.state)) { + record.controller.abort(); + await Promise.allSettled([record.polling, record.transition].filter(Boolean)); + if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled'; + record.error ??= safeError('cancelled', '扫码绑定已取消。'); + this.#finishAttempt(record); + } + return publicAttempt(record); + } + + async reconnectBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error('Unknown Enterprise WeChat bot'); + await this.#withBotTransition(botId, async () => { + const secret = await this.#resolveSecret(config.secretRef); + if (!secret) throw new Error('Enterprise WeChat bot secret is missing'); + try { + await this.#startRuntime(config, secret); + this.#errors.delete(botId); + } catch (error) { + this.#errors.set(botId, safeError('connection-failed', '企业微信连接仍未就绪,请稍后重试。')); + throw error; + } finally { + this.#touch(); + } + }); + return this.status(); + } + + async deleteBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error('Unknown Enterprise WeChat bot'); + await this.#withBotTransition(botId, async () => { + const previous = await this.#credentials.resolve(config.secretRef).catch(() => undefined); + await this.#stopRuntime(botId); + try { + await this.#credentials.unset(config.secretRef); + await this.#configStore.remove(botId); + } catch (error) { + if (previous?.value) { + await this.#credentials.set(config.secretRef, previous.value).catch(() => undefined); + await this.#startRuntime(config, previous.value).catch(() => undefined); + } + throw new Error('Unable to remove the Enterprise WeChat bot safely.', { cause: error }); + } + await this.#deleteState({ botId, config }).catch((error) => { + this.#logger.warn?.(`[dsh-im:wecom] bot ${botId} state cleanup failed:`, error); + }); + this.#errors.delete(botId); + this.#touch(); + }); + return this.status(); + } + + status() { + const bots = this.#configStore.list().map((config) => { + const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null; + const connected = runtimeStatus?.ready === true + && runtimeStatus.wecomConnectionState === 'connected' + && runtimeStatus.harnessReachable === true; + const state = connected ? 'connected' + : runtimeStatus?.wecomConnectionState === 'connecting' ? 'connecting' + : this.#errors.has(config.botId) || runtimeStatus?.wecomConnectionState === 'failed' + ? 'error' : 'offline'; + return { + botId: config.botId, + state, + connected, + configured: true, + bot: { name: '企业微信机器人', appIdMasked: maskWecomBotId(config.remoteBotId) }, + health: { + status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline', + summary: connected ? '企业微信 WebSocket 长连接运行正常' + : state === 'error' ? '企业微信连接未就绪,插件会自动重试' : '企业微信连接当前离线', + lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null, + lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null, + }, + stats: { + messagesReceived: runtimeStatus?.messagesReceived ?? 0, + messagesReplied: runtimeStatus?.messagesReplied ?? 0, + }, + error: structuredClone(this.#errors.get(config.botId) ?? null), + }; + }); + const connectedCount = bots.filter((bot) => bot.connected).length; + const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null; + return { + schemaVersion: 1, + revision: this.#revision, + state: active && ACTIVE_ATTEMPT_STATES.has(active.state) ? 'provisioning' + : bots.length === 0 ? 'disconnected' + : connectedCount === bots.length ? 'connected' + : connectedCount > 0 ? 'degraded' : 'offline', + bots, + totals: { configured: bots.length, connected: connectedCount }, + ...(active && ACTIVE_ATTEMPT_STATES.has(active.state) + ? { provisioning: publicAttempt(active) } : {}), + }; + } + + async close() { + if (this.#closed) return; + this.#closed = true; + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); + } + + async #pollAttempt(record) { + try { + const result = await this.#qrAuth.poll({ scode: record.scode, signal: record.controller.signal }); + if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return; + if (result.status === 'waiting') return; + if (result.status === 'expired') { + record.state = 'expired'; + record.error = safeError('expired', '企业微信二维码已过期,请重新生成。'); + this.#finishAttempt(record); + return; + } + if (result.status !== 'success') { + record.state = 'failed'; + record.error = safeError('qr-connect-failed', '企业微信扫码没有完成,请重新生成二维码。'); + this.#finishAttempt(record); + return; + } + record.state = 'connecting'; + record.verificationUrl = null; + record.expiresAt = null; + record.scode = null; + this.#touch(); + const transition = this.#completeProvisioning(record, result); + record.transition = transition; + await transition; + } catch (error) { + if (record.controller.signal.aborted) return; + record.state = 'failed'; + record.error = safeError('qr-connect-failed', '企业微信扫码服务暂时不可用,请重新生成二维码。'); + this.#logger.warn?.('[dsh-im:wecom] QR polling failed'); + this.#finishAttempt(record); + } + } + + async #completeProvisioning(record, result) { + try { + const remoteBotId = cleanString(result.remoteBotId); + const secret = cleanString(result.secret); + if (!remoteBotId || !secret) throw new Error('Enterprise WeChat authorization returned incomplete credentials'); + record.botId = await this.#activateBot(record, { remoteBotId, secret }); + record.state = 'connected'; + record.error = null; + } catch (error) { + if (record.controller.signal.aborted) { + record.state = 'cancelled'; + record.error = safeError('cancelled', '扫码绑定已取消。'); + } else { + record.state = 'failed'; + record.error = safeError('activation-failed', '企业微信已授权,但无法安全保存接入配置。'); + this.#logger.error?.('[dsh-im:wecom] provisioning failed'); + } + } finally { + this.#finishAttempt(record); + } + } + + async #activateBot(record, { remoteBotId, secret }) { + const identity = deriveWecomBotIdentity(remoteBotId); + const previousConfig = this.#configStore.getByRemoteBotId(remoteBotId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined); + const config = { + botId: identity.botId, + remoteBotId, + secretRef: identity.secretRef, + createdAt: previousConfig?.createdAt ?? new Date().toISOString(), + connectedAt: new Date().toISOString(), + }; + return this.#withBotTransition(identity.botId, async () => { + await this.#credentials.set(identity.secretRef, secret); + try { + if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError'); + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + try { + if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError'); + await this.#startRuntime(config, secret); + this.#errors.delete(identity.botId); + } catch (error) { + if (record.controller.signal.aborted) { + await this.#stopRuntime(identity.botId); + if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined); + else await this.#configStore.remove(identity.botId).catch(() => undefined); + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + this.#errors.set(identity.botId, safeError('connection-failed', '企业微信机器人已绑定,消息连接暂未就绪。')); + this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} activation connection failed`); + } + this.#touch(); + return identity.botId; + }); + } + + async #startRuntime(config, secret) { + await this.#stopRuntime(config.botId); + 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'); + } + this.#runtimes.set(config.botId, runtime); + try { + await runtime.start(); + } catch (error) { + await runtime.stop().catch(() => undefined); + this.#runtimes.delete(config.botId); + throw error; + } + } + + async #stopRuntime(botId) { + const runtime = this.#runtimes.get(botId); + this.#runtimes.delete(botId); + await runtime?.stop().catch((error) => { + this.#logger.warn?.(`[dsh-im:wecom] bot ${botId} failed to stop cleanly:`, error); + }); + } + + async #resolveSecret(ref) { + const result = await this.#credentials.resolve(ref).catch(() => undefined); + return cleanString(result?.value); + } + + async #restoreCredential(ref, previous) { + if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined); + else await this.#credentials.unset(ref).catch(() => undefined); + } + + #withBotTransition(botId, operation) { + const previous = this.#transitions.get(botId) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + const settled = current.finally(() => { + if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId); + }); + this.#transitions.set(botId, settled); + return settled; + } + + #finishAttempt(record) { + record.scode = null; + record.verificationUrl = null; + record.expiresAt = null; + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + const terminal = [...this.#attempts.values()].filter((attempt) => TERMINAL_ATTEMPT_STATES.has(attempt.state)); + while (terminal.length > 16) this.#attempts.delete(terminal.shift().id); + } + + #touch() { + this.#revision += 1; + } +} diff --git a/src/channels/wecom/wecom-runtime.mjs b/src/channels/wecom/wecom-runtime.mjs new file mode 100644 index 0000000..d2f9fd9 --- /dev/null +++ b/src/channels/wecom/wecom-runtime.mjs @@ -0,0 +1,209 @@ +import { WSAuthFailureError, WSClient, WSReconnectExhaustedError } from '@wecom/aibot-node-sdk'; + +import { createWecomBridgeStatus, WecomHarnessBridge } from './wecom-bridge.mjs'; + +function timeoutError() { + const error = new Error('Enterprise WeChat WebSocket authentication timed out'); + error.code = 'connect-timeout'; + return error; +} + +export function createWecomRuntimeStatus() { + return { + startedAt: null, + ready: false, + wecomConnectionState: 'idle', + harnessReachable: false, + lastCheckedAt: null, + lastConnectedAt: null, + lastError: null, + ...createWecomBridgeStatus(), + }; +} + +export class WecomRuntime { + #config; + #secret; + #harness; + #state; + #logger; + #replyTimeoutMs; + #connectTimeoutMs; + #maxReconnectAttempts; + #createClient; + #status = createWecomRuntimeStatus(); + #client = null; + #bridge = null; + #starting = null; + #startController = null; + + constructor({ + config, + secret, + harness, + state, + logger = console, + replyTimeoutMs = 600_000, + connectTimeoutMs = 20_000, + maxReconnectAttempts = 10, + createClient = (options) => new WSClient(options), + }) { + if (!config || !secret || !harness || !state) { + throw new TypeError('WecomRuntime requires config, secret, Harness, and state'); + } + this.#config = config; + this.#secret = secret; + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#connectTimeoutMs = connectTimeoutMs; + this.#maxReconnectAttempts = maxReconnectAttempts; + this.#createClient = createClient; + } + + get status() { + return structuredClone(this.#status); + } + + async start() { + if (this.#status.ready && this.#client) return this.status; + if (this.#starting) return this.#starting; + const controller = new AbortController(); + this.#startController = controller; + this.#starting = this.#start(controller.signal).finally(() => { + if (this.#startController === controller) this.#startController = null; + this.#starting = null; + }); + return this.#starting; + } + + async #start(signal) { + await this.#stopActive(); + signal.throwIfAborted(); + this.#status.startedAt = new Date().toISOString(); + this.#status.wecomConnectionState = 'connecting'; + this.#status.lastError = null; + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + + const silentSdkLogger = { debug() {}, info() {}, warn() {}, error() {} }; + const client = this.#createClient({ + botId: this.#config.remoteBotId, + secret: this.#secret, + logger: silentSdkLogger, + maxReconnectAttempts: this.#maxReconnectAttempts, + }); + if (!client || typeof client.connect !== 'function' || typeof client.disconnect !== 'function') { + throw new TypeError('Enterprise WeChat client factory returned an invalid client'); + } + this.#client = client; + this.#bridge = new WecomHarnessBridge({ + client, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs, + }); + + let readyResolve; + let readyReject; + let authenticated = false; + const ready = new Promise((resolve, reject) => { + readyResolve = resolve; + readyReject = reject; + }); + const onAuthenticated = () => { + if (this.#client !== client || signal.aborted) return; + authenticated = true; + const now = Date.now(); + this.#status.ready = true; + this.#status.wecomConnectionState = 'connected'; + this.#status.lastCheckedAt = now; + this.#status.lastConnectedAt = now; + this.#status.lastError = null; + readyResolve(); + }; + const onDisconnected = () => { + if (this.#client !== client) return; + this.#status.ready = false; + this.#status.wecomConnectionState = 'connecting'; + this.#status.lastCheckedAt = Date.now(); + }; + const onReconnecting = () => { + if (this.#client !== client) return; + this.#status.ready = false; + this.#status.wecomConnectionState = 'connecting'; + this.#status.lastCheckedAt = Date.now(); + }; + const onError = (error) => { + if (this.#client !== client) return; + const terminal = error instanceof WSAuthFailureError || error instanceof WSReconnectExhaustedError; + if (!authenticated && terminal) readyReject(error); + if (terminal) { + this.#status.ready = false; + this.#status.wecomConnectionState = 'failed'; + } + this.#status.lastError = terminal ? error.name : 'connection-error'; + this.#logger.warn?.(`[dsh-im:wecom] bot ${this.#config.botId} connection error`); + }; + const onMessage = (frame) => this.#bridge?.accept(frame); + client.on('authenticated', onAuthenticated); + client.on('disconnected', onDisconnected); + client.on('reconnecting', onReconnecting); + client.on('error', onError); + client.on('message', onMessage); + + let timer; + try { + client.connect(); + await Promise.race([ + ready, + new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(timeoutError()), this.#connectTimeoutMs); + }), + ]); + return this.status; + } catch (error) { + if (signal.aborted) { + await this.#stopActive(); + throw error; + } + this.#status.ready = false; + this.#status.wecomConnectionState = 'failed'; + this.#status.lastError = error?.message ?? String(error); + await this.#stopActive(); + throw error; + } finally { + clearTimeout(timer); + } + } + + async stop() { + const starting = this.#starting; + this.#startController?.abort(new DOMException('Enterprise WeChat runtime stopped', 'AbortError')); + await this.#stopActive(); + await starting?.catch(() => undefined); + return this.status; + } + + async #stopActive() { + const client = this.#client; + const bridge = this.#bridge; + this.#client = null; + this.#bridge = null; + try { + client?.disconnect(); + client?.removeAllListeners?.(); + } catch (error) { + this.#logger.warn?.(`[dsh-im:wecom] bot ${this.#config.botId} failed to stop cleanly`); + } + await bridge?.waitForIdle(); + this.#status.ready = false; + this.#status.wecomConnectionState = 'idle'; + } +} diff --git a/test/channels/wecom/bridge.test.mjs b/test/channels/wecom/bridge.test.mjs new file mode 100644 index 0000000..506c601 --- /dev/null +++ b/test/channels/wecom/bridge.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { WecomHarnessBridge } from '../../../src/channels/wecom/wecom-bridge.mjs'; + +function frame(overrides = {}) { + return { + headers: { req_id: 'req-1' }, + body: { + msgid: 'msg-1', + chattype: 'single', + from: { userid: 'member-1' }, + msgtype: 'text', + text: { content: '请回答' }, + ...overrides, + }, + }; +} + +function state() { + const seen = new Set(); + return { + seen, + hasSeen: (id) => seen.has(id), + markSeen: async (id) => seen.add(id), + sessionFor: () => 'session-existing', + sessionExists: async () => true, + setSession: async () => {}, + clearSession: async () => {}, + }; +} + +test('Enterprise WeChat messages stream Harness progress and finalize once', async () => { + const replies = []; + const active = []; + const store = state(); + const bridge = new WecomHarnessBridge({ + client: { + replyStream: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }), + replyStreamNonBlocking: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }), + sendMessage: async (chatId, body) => active.push({ chatId, body }), + }, + generateStreamId: () => 'stream-1', + harness: { + sessionExists: async () => true, + createSession: async () => 'session-new', + ensureRunning: async () => true, + ask: async (_session, _text, { onUpdate }) => { + await onUpdate({ type: 'tool', name: '网页搜索' }); + await onUpdate({ type: 'text', text: '回答中' }); + return '最终回答'; + }, + }, + state: store, + }); + + await bridge.accept(frame()); + assert.deepEqual(replies, [ + { streamId: 'stream-1', content: '正在思考中…', finish: false }, + { streamId: 'stream-1', content: '正在使用网页搜索…', finish: false }, + { streamId: 'stream-1', content: '回答中', finish: false }, + { streamId: 'stream-1', content: '最终回答', finish: true }, + ]); + assert.deepEqual(active, []); + assert.equal(store.seen.has('msg-1'), true); + assert.equal(bridge.status.messagesReplied, 1); +}); + +test('Enterprise WeChat visibility scope accepts direct and group conversations without local approval', async () => { + let asks = 0; + const client = { + replyStream: async () => {}, + replyStreamNonBlocking: async () => {}, + sendMessage: async () => {}, + }; + const harness = { + sessionExists: async () => true, + ask: async () => { asks += 1; return 'ok'; }, + }; + const bridge = new WecomHarnessBridge({ client, harness, state: state(), generateStreamId: () => 'stream' }); + await bridge.accept(frame({ msgid: 'direct', from: { userid: 'member-a' } })); + await bridge.accept(frame({ msgid: 'group', chattype: 'group', chatid: 'group-1', from: { userid: 'member-b' } })); + assert.equal(asks, 2); +}); + +test('Enterprise WeChat finalizes an existing progress stream when Harness fails', async () => { + const replies = []; + const store = state(); + const bridge = new WecomHarnessBridge({ + client: { + replyStream: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }), + replyStreamNonBlocking: async () => {}, + sendMessage: async () => {}, + }, + generateStreamId: () => 'stream-failure', + harness: { + sessionExists: async () => true, + ensureRunning: async () => true, + ask: async () => { throw new Error('Harness unavailable'); }, + }, + state: store, + logger: { error() {} }, + }); + + await bridge.accept(frame()); + assert.deepEqual(replies, [ + { streamId: 'stream-failure', content: '正在思考中…', finish: false }, + { streamId: 'stream-failure', content: '消息处理失败,请稍后重试。', finish: true }, + ]); + assert.equal(store.seen.has('msg-1'), true); +}); diff --git a/test/channels/wecom/client-api.test.mjs b/test/channels/wecom/client-api.test.mjs new file mode 100644 index 0000000..a2ab170 --- /dev/null +++ b/test/channels/wecom/client-api.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + normalizeProvisioning, + normalizeSnapshot, + safeQrSource, +} from '../../../plugin-src/client/channels/wecom/api.js'; + +test('Enterprise WeChat client keeps only redacted bot and Host-rendered QR state', () => { + const qr = 'data:image/png;base64,YWJjZA=='; + assert.equal(safeQrSource(qr), qr); + const provision = normalizeProvisioning({ + attemptId: 'attempt_1', status: 'pending', expiresAt: Date.now() + 1_000, qrCodeDataUrl: qr, + }); + assert.equal(provision.qrCodeDataUrl, qr); + const snapshot = normalizeSnapshot({ + bots: [{ + botId: 'wecom_abc', connected: true, state: 'connected', + bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' }, + health: { summary: '运行正常' }, + }], + }); + assert.equal(snapshot.totals.connected, 1); + assert.equal(snapshot.bots[0].bot.appIdMasked, 'bot••••001'); +}); diff --git a/test/channels/wecom/client-ui.test.mjs b/test/channels/wecom/client-ui.test.mjs new file mode 100644 index 0000000..e361fa0 --- /dev/null +++ b/test/channels/wecom/client-ui.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { + AccountCard, + WecomSettingsTab, +} from '../../../plugin-src/client/channels/wecom/index.js'; + +test('Enterprise WeChat settings uses the shared compact channel toolbar', () => { + const markup = renderToStaticMarkup(React.createElement(WecomSettingsTab, { + rpcCall: async () => ({ ok: true, value: {} }), + })); + assert.match(markup, /class="ddt-page dwecom-page dim-channelPage"/); + assert.match(markup, /class="ddt-button dim-scanButton"/); + assert.match(markup, />扫码绑定企业微信机器人 { + const markup = renderToStaticMarkup(React.createElement(AccountCard, { + account: { + botId: 'wecom_bot', + connected: true, + state: 'connected', + bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' }, + health: { summary: '企业微信 WebSocket 长连接运行正常', lastCheckedAt: Date.now() }, + error: null, + }, + onReconnect() {}, + onRequestRemove() {}, + onConfirmRemove() {}, + onCancelRemove() {}, + })); + assert.match(markup, /class="ddt-card dim-botCard"/); + assert.match(markup, /data-im-channel-logo="wecom"/); + assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2); + assert.match(markup, />消息通道<[^]*>最近检查检查连接<[^]*>移除接入 { + const root = await mkdtemp(join(tmpdir(), 'dsh-im-wecom-config-')); + t.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, 'config.json'); + const identity = deriveWecomBotIdentity('bot-enterprise-1'); + const store = await new WecomConfigStore(path).load(); + await store.save({ + ...identity, + remoteBotId: 'bot-enterprise-1', + createdAt: '2026-08-15T00:00:00.000Z', + }); + const document = await readFile(path, 'utf8'); + assert.match(document, /bot-enterprise-1/); + assert.doesNotMatch(document, /private-secret|"secret"/); + assert.equal((await stat(path)).mode & 0o777, 0o600); + assert.equal(store.get(identity.botId).remoteBotId, 'bot-enterprise-1'); +}); + +test('Enterprise WeChat QR auth uses fixed official endpoints and returns credentials only after success', async () => { + const requests = []; + const replies = [ + { data: { scode: 'opaque-scode', auth_url: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque' } }, + { data: { status: 'init' } }, + { data: { status: 'success', bot_info: { botid: 'bot-enterprise-1', secret: 'private-secret' } } }, + ]; + const auth = new WecomQrAuth({ + clock: () => 1_000, + fetch: async (url, options) => { + requests.push({ url: String(url), options }); + return { ok: true, status: 200, json: async () => replies.shift() }; + }, + }); + const started = await auth.start(); + assert.equal(started.verificationUrl, 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque'); + assert.equal(started.expiresAt, 301_000); + assert.equal(await auth.poll({ scode: started.scode }).then((value) => value.status), 'waiting'); + const completed = await auth.poll({ scode: started.scode }); + assert.deepEqual(completed, { + status: 'success', remoteBotId: 'bot-enterprise-1', secret: 'private-secret', + }); + assert.match(requests[0].url, /^https:\/\/work\.weixin\.qq\.com\/ai\/qc\/generate\?/); + assert.match(requests[0].url, /source=deepseek-harness/); + assert.match(requests[1].url, /^https:\/\/work\.weixin\.qq\.com\/ai\/qc\/query_result\?/); + assert.equal(requests.every(({ options }) => options.redirect === 'error'), true); +}); + +test('Enterprise WeChat QR auth rejects an authorization URL outside the official host', async () => { + const auth = new WecomQrAuth({ + fetch: async () => ({ + ok: true, + status: 200, + json: async () => ({ data: { scode: 'opaque', auth_url: 'https://example.com/steal' } }), + }), + }); + await assert.rejects(() => auth.start(), /invalid data/); +}); diff --git a/test/channels/wecom/controller-and-rpc.test.mjs b/test/channels/wecom/controller-and-rpc.test.mjs new file mode 100644 index 0000000..c6122da --- /dev/null +++ b/test/channels/wecom/controller-and-rpc.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { WecomController } from '../../../src/channels/wecom/wecom-controller.mjs'; +import { + createWecomRpcHandler, + WECOM_ENDPOINTS, +} from '../../../plugin-src/host/channels/wecom/rpc.mjs'; + +test('Enterprise WeChat QR success stores Secret off-config and starts its runtime', async () => { + const values = new Map(); + const configs = []; + let runtimeArgs; + const controller = new WecomController({ + qrAuth: { + start: async () => ({ + scode: 'host-only-code', + verificationUrl: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque', + expiresAt: Date.now() + 10_000, + pollIntervalMs: 500, + }), + poll: async () => ({ status: 'success', remoteBotId: 'remote-bot', secret: 'private-secret' }), + }, + 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 started = await controller.startProvisioning(); + const completed = await controller.registrationStatus(started.attemptId); + assert.equal(completed.status, 'connected'); + const status = controller.status(); + assert.equal(status.bots[0].connected, true); + assert.equal(values.get(configs[0].secretRef), 'private-secret'); + assert.equal(runtimeArgs.secret, 'private-secret'); + assert.doesNotMatch(JSON.stringify(status), /private-secret|secretRef|remote-bot|host-only-code/); +}); + +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 } }), + startProvisioning: async () => ({ + attemptId: 'attempt_1', + status: 'pending', + verificationUrl: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque', + scode: 'never-public', + secret: 'never-public', + remoteBotId: 'never-public', + expiresAt: Date.now() + 1_000, + }), + registrationStatus: async () => null, + cancelProvisioning: async () => ({}), + reconnectBot: async () => ({}), + deleteBot: async () => ({}), + }, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' }); + const result = await handler(WECOM_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' }); + assert.equal(result.ok, true); + assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA=='); + assert.doesNotMatch(JSON.stringify(result), /work\.weixin|never-public|remoteBotId|scode|secret/); +}); diff --git a/test/channels/wecom/runtime.test.mjs b/test/channels/wecom/runtime.test.mjs new file mode 100644 index 0000000..54cf025 --- /dev/null +++ b/test/channels/wecom/runtime.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { WecomRuntime } from '../../../src/channels/wecom/wecom-runtime.mjs'; + +class FakeClient extends EventEmitter { + disconnected = false; + connect() { queueMicrotask(() => this.emit('authenticated')); } + disconnect() { this.disconnected = true; } + async replyStream() {} + async replyStreamNonBlocking() {} + async sendMessage() {} +} + +test('Enterprise WeChat runtime waits for authentication, suppresses SDK payload logs, and reconnects', async () => { + const client = new FakeClient(); + let options; + const logs = []; + const runtime = new WecomRuntime({ + config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' }, + secret: 'private-secret', + harness: { ensureRunning: async () => true }, + state: {}, + createClient: (value) => { options = value; return client; }, + logger: { debug: (...args) => logs.push(args), warn() {} }, + connectTimeoutMs: 100, + }); + const status = await runtime.start(); + assert.equal(status.ready, true); + assert.equal(status.wecomConnectionState, 'connected'); + assert.equal(options.botId, 'remote-bot'); + assert.equal(options.secret, 'private-secret'); + options.logger.debug('raw message payload'); + options.logger.warn('raw unknown frame'); + assert.deepEqual(logs, []); + client.emit('disconnected', 'network'); + assert.equal(runtime.status.ready, false); + assert.equal(runtime.status.wecomConnectionState, 'connecting'); + client.emit('authenticated'); + assert.equal(runtime.status.ready, true); + await runtime.stop(); + assert.equal(client.disconnected, true); + assert.equal(runtime.status.ready, false); +}); + +test('Enterprise WeChat runtime never reports ready without SDK authentication', async () => { + const client = new FakeClient(); + client.connect = () => {}; + const runtime = new WecomRuntime({ + config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' }, + secret: 'private-secret', + harness: { ensureRunning: async () => true }, + state: {}, + createClient: () => client, + connectTimeoutMs: 5, + }); + await assert.rejects(() => runtime.start(), /authentication timed out/); + assert.equal(runtime.status.ready, false); + assert.equal(client.disconnected, true); +}); + +test('Enterprise WeChat runtime stop cancels an in-flight authentication wait', async () => { + const client = new FakeClient(); + client.connect = () => {}; + const runtime = new WecomRuntime({ + config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' }, + secret: 'private-secret', + harness: { ensureRunning: async () => true }, + state: {}, + createClient: () => client, + connectTimeoutMs: 60_000, + }); + const starting = runtime.start(); + await new Promise((resolve) => setImmediate(resolve)); + await runtime.stop(); + await assert.rejects(starting, { name: 'AbortError' }); + assert.equal(client.disconnected, true); + assert.equal(runtime.status.wecomConnectionState, 'idle'); +}); diff --git a/test/client-ui.test.mjs b/test/client-ui.test.mjs index 5d995e9..849480a 100644 --- a/test/client-ui.test.mjs +++ b/test/client-ui.test.mjs @@ -19,6 +19,10 @@ import { AccountCard as WeixinAccountCard, WeixinSettingsTab, } from '../plugin-src/client/channels/weixin/index.js'; +import { + AccountCard as WecomAccountCard, + WecomSettingsTab, +} from '../plugin-src/client/channels/wecom/index.js'; const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url); const FEISHU_STYLES_URL = new URL( @@ -33,6 +37,10 @@ const DINGTALK_STYLES_URL = new URL( '../plugin-src/client/channels/dingtalk/styles.js', import.meta.url, ); +const WECOM_STYLES_URL = new URL( + '../plugin-src/client/channels/wecom/styles.js', + import.meta.url, +); const FEISHU_SOURCE_URL = new URL( '../plugin-src/client/channels/feishu/index.js', import.meta.url, @@ -46,12 +54,17 @@ const DINGTALK_CLIENT_SOURCE_URL = new URL( '../plugin-src/client/channels/dingtalk/index.js', import.meta.url, ); +const WECOM_SOURCE_URL = new URL( + '../plugin-src/client/channels/wecom/index.js', + import.meta.url, +); -test('IM settings renders four compact logo channel tabs without enable switches', () => { +test('IM settings renders five compact logo channel tabs without enable switches', () => { const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, { feishuRpcCall: async () => ({ ok: true, value: {} }), weixinRpcCall: async () => ({ ok: true, value: {} }), dingtalkRpcCall: async () => ({ ok: true, value: {} }), + wecomRpcCall: async () => ({ ok: true, value: {} }), qqRpcCall: async () => ({ ok: true, value: {} }), })); @@ -61,12 +74,14 @@ test('IM settings renders four compact logo channel tabs without enable switches assert.match(markup, />微信飞书钉钉企业微信QQ|扫码接入<\/small>/); @@ -138,20 +153,23 @@ test('Feishu keeps its heading controls on one row without a plus icon', async ( }); test('scan actions align left while online totals align right in every channel', async () => { - const [imStyles, feishuStyles, weixinStyles, dingtalkStyles, feishuSource, weixinSource, dingtalkSource] = await Promise.all([ + const [imStyles, feishuStyles, weixinStyles, dingtalkStyles, wecomStyles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([ readFile(STYLES_URL, 'utf8'), readFile(FEISHU_STYLES_URL, 'utf8'), readFile(WEIXIN_STYLES_URL, 'utf8'), readFile(DINGTALK_STYLES_URL, 'utf8'), + readFile(WECOM_STYLES_URL, 'utf8'), readFile(FEISHU_SOURCE_URL, 'utf8'), readFile(WEIXIN_SOURCE_URL, 'utf8'), readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'), + readFile(WECOM_SOURCE_URL, 'utf8'), ]); assert.match(imStyles, /\.dim-panel \.bxf-headingTools, \.dim-panel \.dxw-tools, \.dim-panel \.ddt-tools \{[^}]*justify-content: space-between;/); assert.match(feishuStyles, /\.bxf-headingTools \{[^}]*justify-content: space-between;/); assert.match(weixinStyles, /\.dxw-tools \{[^}]*justify-content: space-between;/); assert.match(dingtalkStyles, /\.ddt-tools \{[^}]*justify-content: space-between;/); + assert.match(wecomStyles, /\.dwecom-page/); const headingSource = (source) => source.slice( source.indexOf('function Heading'), @@ -160,11 +178,13 @@ test('scan actions align left while online totals align right in every channel', const feishuHeading = headingSource(feishuSource); 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')); - for (const heading of [feishuHeading, weixinHeading, dingtalkHeading]) { + for (const heading of [feishuHeading, weixinHeading, dingtalkHeading, wecomHeading]) { assert.match(heading, /dim-scanButton/); assert.match(heading, /dim-onlineBadge/); } @@ -175,7 +195,7 @@ test('scan actions align left while online totals align right in every channel', }); test('channel headings omit the redundant local credential badge', () => { - const components = [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab]; + const components = [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab, WecomSettingsTab]; for (const Component of components) { const markup = renderToStaticMarkup(React.createElement(Component, { @@ -186,14 +206,15 @@ test('channel headings omit the redundant local credential badge', () => { }); test('all channel settings states use the DingTalk page treatment', async () => { - const [styles, feishuSource, weixinSource, dingtalkSource] = await Promise.all([ + const [styles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([ readFile(STYLES_URL, 'utf8'), readFile(FEISHU_SOURCE_URL, 'utf8'), readFile(WEIXIN_SOURCE_URL, 'utf8'), readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'), + readFile(WECOM_SOURCE_URL, 'utf8'), ]); - for (const Component of [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab]) { + for (const Component of [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab, WecomSettingsTab]) { const markup = renderToStaticMarkup(React.createElement(Component, { rpcCall: async () => ({ ok: true, value: {} }), })); @@ -202,7 +223,7 @@ test('all channel settings states use the DingTalk page treatment', async () => assert.match(markup, /dim-spinner/); } - for (const source of [feishuSource, weixinSource, dingtalkSource]) { + for (const source of [feishuSource, weixinSource, dingtalkSource, wecomSource]) { for (const className of [ 'dim-channelPage', 'dim-surfaceCard', @@ -232,6 +253,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => { feishuRpcCall: async () => ({ ok: true, value: {} }), weixinRpcCall: async () => ({ ok: true, value: {} }), dingtalkRpcCall: async () => ({ ok: true, value: {} }), + wecomRpcCall: async () => ({ ok: true, value: {} }), })); const accountMarkup = renderToStaticMarkup(React.createElement(WeixinAccountCard, { account: { @@ -250,6 +272,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => { assert.match(railMarkup, /data-im-channel-logo="weixin"/); assert.match(railMarkup, /data-im-channel-logo="feishu"/); + assert.match(railMarkup, /data-im-channel-logo="wecom"/); assert.match(accountMarkup, /class="dxw-card dim-botCard"/); assert.match(accountMarkup, /class="dxw-avatar dim-botAvatar"[^]*data-im-channel-logo="weixin"/); assert.match(accountMarkup, /class="dxw-health dim-botHealth"/); @@ -259,6 +282,20 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => { assert.doesNotMatch(accountMarkup, /收到 \/ 回复/); }); +test('Enterprise WeChat cards reuse the rail logo and compact action treatment', () => { + const markup = renderToStaticMarkup(React.createElement(WecomAccountCard, { + account: { + botId: 'wecom-card', state: 'connected', connected: true, + bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' }, + health: { summary: '企业微信 WebSocket 长连接运行正常', lastCheckedAt: Date.now() }, + }, + onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {}, + })); + assert.match(markup, /data-im-channel-logo="wecom"/); + assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2); + assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2); +}); + test('DingTalk bot cards omit the redundant received and replied metric', () => { const markup = renderToStaticMarkup(React.createElement(DingtalkAccountCard, { account: { diff --git a/test/host.test.mjs b/test/host.test.mjs index 5c844a5..cf5e877 100644 --- a/test/host.test.mjs +++ b/test/host.test.mjs @@ -3,12 +3,13 @@ import test from 'node:test'; import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs'; -test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context', async () => { +test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside one plugin context', async () => { const calls = []; const plugin = createImHostPlugin({ applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]), applyWeixin: async (ctx, config) => calls.push(['weixin', ctx, config]), applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]), + applyWecom: async (ctx, config) => calls.push(['wecom', ctx, config]), applyQq: async (ctx, config) => calls.push(['qq', ctx, config]), }); const ctx = { marker: 'shared-context' }; @@ -16,6 +17,7 @@ test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context', feishu: { domain: 'feishu' }, weixin: { timeout: 30 }, dingtalk: { replyTimeoutMs: 60_000 }, + wecom: { replyTimeoutMs: 60_000 }, qq: { replyTimeoutMs: 60_000 }, }; @@ -27,6 +29,7 @@ test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context', ['feishu', ctx, config.feishu], ['weixin', ctx, config.weixin], ['dingtalk', ctx, config.dingtalk], + ['wecom', ctx, config.wecom], ['qq', ctx, config.qq], ]); }); @@ -37,6 +40,7 @@ test('Host does not start Weixin when Feishu activation fails', async () => { applyFeishu: async () => { throw new Error('feishu unavailable'); }, applyWeixin: async () => { weixinStarted = true; }, applyDingtalk: async () => { throw new Error('DingTalk must not start'); }, + applyWecom: async () => { throw new Error('Enterprise WeChat must not start'); }, applyQq: async () => { throw new Error('QQ must not start'); }, }); @@ -50,6 +54,7 @@ test('Host does not start DingTalk when Weixin activation fails', async () => { applyFeishu: async () => {}, applyWeixin: async () => { throw new Error('weixin unavailable'); }, applyDingtalk: async () => { dingtalkStarted = true; }, + applyWecom: async () => { throw new Error('Enterprise WeChat must not start'); }, applyQq: async () => { throw new Error('QQ must not start'); }, }); @@ -57,15 +62,30 @@ test('Host does not start DingTalk when Weixin activation fails', async () => { assert.equal(dingtalkStarted, false); }); -test('Host does not start QQ when DingTalk activation fails', async () => { - let qqStarted = false; +test('Host does not start Enterprise WeChat when DingTalk activation fails', async () => { + let wecomStarted = false; const plugin = createImHostPlugin({ applyFeishu: async () => {}, applyWeixin: async () => {}, applyDingtalk: async () => { throw new Error('dingtalk unavailable'); }, - applyQq: async () => { qqStarted = true; }, + applyWecom: async () => { wecomStarted = true; }, + applyQq: async () => { throw new Error('QQ must not start'); }, }); await assert.rejects(() => plugin.apply({}, {}), /dingtalk unavailable/); + assert.equal(wecomStarted, false); +}); + +test('Host does not start QQ when Enterprise WeChat activation fails', async () => { + let qqStarted = false; + const plugin = createImHostPlugin({ + applyFeishu: async () => {}, + applyWeixin: async () => {}, + applyDingtalk: async () => {}, + applyWecom: async () => { throw new Error('wecom unavailable'); }, + applyQq: async () => { qqStarted = true; }, + }); + + await assert.rejects(() => plugin.apply({}, {}), /wecom unavailable/); assert.equal(qqStarted, false); });