From bd469f58f839e52acac12cdd70a6d57f1ceeeac4 Mon Sep 17 00:00:00 2001 From: xmanrui <841206367@qq.com> Date: Sat, 15 Aug 2026 15:40:53 +0800 Subject: [PATCH] Internalize all IM channel implementations --- .gitignore | 1 + README.md | 2 + THIRD_PARTY_NOTICES.md | 8 +- lib/client.js | 8 +- lib/index.js | 7982 ++++++++++++++++- package-lock.json | 115 +- package.json | 11 +- plugin-src/client/channels/dingtalk/api.js | 202 + plugin-src/client/channels/dingtalk/index.js | 661 ++ plugin-src/client/channels/dingtalk/styles.js | 141 + plugin-src/client/index.js | 4 +- .../dingtalk/connection-supervisor.mjs | 130 + plugin-src/host/channels/dingtalk/index.mjs | 32 + .../host/channels/dingtalk/production.mjs | 122 + plugin-src/host/channels/dingtalk/rpc.mjs | 221 + .../channels/feishu/connection-supervisor.mjs | 167 + .../host/channels/feishu/controller.mjs | 172 + .../host/channels/feishu/credential-store.mjs | 84 + plugin-src/host/channels/feishu/index.mjs | 66 + .../host/channels/feishu/production.mjs | 138 + plugin-src/host/channels/feishu/rpc.mjs | 434 + .../channels/weixin/connection-supervisor.mjs | 127 + plugin-src/host/channels/weixin/index.mjs | 32 + .../host/channels/weixin/production.mjs | 119 + plugin-src/host/channels/weixin/rpc.mjs | 177 + plugin-src/host/index.mjs | 6 +- scripts/verify-package.mjs | 44 +- src/channels/dingtalk/config-store.mjs | 282 + src/channels/dingtalk/device-auth.mjs | 237 + src/channels/dingtalk/dingtalk-api.mjs | 579 ++ src/channels/dingtalk/dingtalk-bridge.mjs | 281 + .../dingtalk/dingtalk-card-stream.mjs | 233 + src/channels/dingtalk/dingtalk-controller.mjs | 708 ++ src/channels/dingtalk/dingtalk-runtime.mjs | 358 + src/channels/dingtalk/harness-client.mjs | 299 + src/channels/dingtalk/state-store.mjs | 212 + src/channels/feishu/bridge.mjs | 216 + src/channels/feishu/config.mjs | 71 + src/channels/feishu/feishu-app.mjs | 51 + src/channels/feishu/feishu-channel.mjs | 153 + src/channels/feishu/feishu-runtime.mjs | 222 + src/channels/feishu/harness-client.mjs | 268 + src/channels/feishu/message-utils.mjs | 50 + src/channels/feishu/multi-bot-controller.mjs | 616 ++ src/channels/feishu/plugin-config-store.mjs | 204 + src/channels/feishu/plugin-controller.mjs | 248 + src/channels/feishu/registration-manager.mjs | 345 + src/channels/feishu/state-store.mjs | 71 + src/channels/weixin/config-store.mjs | 182 + src/channels/weixin/harness-client.mjs | 259 + src/channels/weixin/state-store.mjs | 112 + src/channels/weixin/weixin-api.mjs | 319 + src/channels/weixin/weixin-bridge.mjs | 173 + src/channels/weixin/weixin-controller.mjs | 545 ++ src/channels/weixin/weixin-runtime.mjs | 204 + test/channels/dingtalk/client-api.test.mjs | 118 + .../dingtalk/client-lifecycle.test.mjs | 272 + test/channels/dingtalk/client-ui.test.mjs | 86 + test/channels/dingtalk/config-store.test.mjs | 127 + .../dingtalk/connection-supervisor.test.mjs | 73 + test/channels/dingtalk/device-auth.test.mjs | 120 + test/channels/dingtalk/dingtalk-api.test.mjs | 334 + .../dingtalk/dingtalk-bridge.test.mjs | 271 + .../dingtalk/dingtalk-card-stream.test.mjs | 199 + .../dingtalk/dingtalk-controller.test.mjs | 428 + .../dingtalk/dingtalk-runtime.test.mjs | 368 + .../channels/dingtalk/harness-client.test.mjs | 74 + test/channels/dingtalk/plugin-host.test.mjs | 44 + test/channels/dingtalk/production.test.mjs | 74 + test/channels/dingtalk/rpc.test.mjs | 96 + test/channels/dingtalk/state-store.test.mjs | 83 + test/channels/feishu/bridge.test.mjs | 240 + test/channels/feishu/client-api.test.mjs | 194 + test/channels/feishu/config.test.mjs | 50 + .../feishu/connection-supervisor.test.mjs | 98 + test/channels/feishu/feishu-app.test.mjs | 48 + test/channels/feishu/feishu-channel.test.mjs | 131 + test/channels/feishu/feishu-runtime.test.mjs | 120 + test/channels/feishu/harness-client.test.mjs | 132 + test/channels/feishu/message-utils.test.mjs | 50 + .../feishu/multi-bot-controller.test.mjs | 485 + .../feishu/plugin-config-store.test.mjs | 101 + .../feishu/plugin-controller.test.mjs | 139 + test/channels/feishu/plugin-host.test.mjs | 716 ++ .../feishu/registration-manager.test.mjs | 280 + test/channels/feishu/state-store.test.mjs | 34 + .../weixin/connection-supervisor.test.mjs | 68 + test/channels/weixin/harness-client.test.mjs | 48 + test/channels/weixin/plugin-host.test.mjs | 92 + test/channels/weixin/stores.test.mjs | 62 + test/channels/weixin/weixin-api.test.mjs | 124 + test/channels/weixin/weixin-bridge.test.mjs | 113 + .../weixin/weixin-controller.test.mjs | 218 + test/channels/weixin/weixin-runtime.test.mjs | 91 + test/client-ui.test.mjs | 7 +- 95 files changed, 25012 insertions(+), 100 deletions(-) create mode 100644 plugin-src/client/channels/dingtalk/api.js create mode 100644 plugin-src/client/channels/dingtalk/index.js create mode 100644 plugin-src/client/channels/dingtalk/styles.js create mode 100644 plugin-src/host/channels/dingtalk/connection-supervisor.mjs create mode 100644 plugin-src/host/channels/dingtalk/index.mjs create mode 100644 plugin-src/host/channels/dingtalk/production.mjs create mode 100644 plugin-src/host/channels/dingtalk/rpc.mjs create mode 100644 plugin-src/host/channels/feishu/connection-supervisor.mjs create mode 100644 plugin-src/host/channels/feishu/controller.mjs create mode 100644 plugin-src/host/channels/feishu/credential-store.mjs create mode 100644 plugin-src/host/channels/feishu/index.mjs create mode 100644 plugin-src/host/channels/feishu/production.mjs create mode 100644 plugin-src/host/channels/feishu/rpc.mjs create mode 100644 plugin-src/host/channels/weixin/connection-supervisor.mjs create mode 100644 plugin-src/host/channels/weixin/index.mjs create mode 100644 plugin-src/host/channels/weixin/production.mjs create mode 100644 plugin-src/host/channels/weixin/rpc.mjs create mode 100644 src/channels/dingtalk/config-store.mjs create mode 100644 src/channels/dingtalk/device-auth.mjs create mode 100644 src/channels/dingtalk/dingtalk-api.mjs create mode 100644 src/channels/dingtalk/dingtalk-bridge.mjs create mode 100644 src/channels/dingtalk/dingtalk-card-stream.mjs create mode 100644 src/channels/dingtalk/dingtalk-controller.mjs create mode 100644 src/channels/dingtalk/dingtalk-runtime.mjs create mode 100644 src/channels/dingtalk/harness-client.mjs create mode 100644 src/channels/dingtalk/state-store.mjs create mode 100644 src/channels/feishu/bridge.mjs create mode 100644 src/channels/feishu/config.mjs create mode 100644 src/channels/feishu/feishu-app.mjs create mode 100644 src/channels/feishu/feishu-channel.mjs create mode 100644 src/channels/feishu/feishu-runtime.mjs create mode 100644 src/channels/feishu/harness-client.mjs create mode 100644 src/channels/feishu/message-utils.mjs create mode 100644 src/channels/feishu/multi-bot-controller.mjs create mode 100644 src/channels/feishu/plugin-config-store.mjs create mode 100644 src/channels/feishu/plugin-controller.mjs create mode 100644 src/channels/feishu/registration-manager.mjs create mode 100644 src/channels/feishu/state-store.mjs create mode 100644 src/channels/weixin/config-store.mjs create mode 100644 src/channels/weixin/harness-client.mjs create mode 100644 src/channels/weixin/state-store.mjs create mode 100644 src/channels/weixin/weixin-api.mjs create mode 100644 src/channels/weixin/weixin-bridge.mjs create mode 100644 src/channels/weixin/weixin-controller.mjs create mode 100644 src/channels/weixin/weixin-runtime.mjs create mode 100644 test/channels/dingtalk/client-api.test.mjs create mode 100644 test/channels/dingtalk/client-lifecycle.test.mjs create mode 100644 test/channels/dingtalk/client-ui.test.mjs create mode 100644 test/channels/dingtalk/config-store.test.mjs create mode 100644 test/channels/dingtalk/connection-supervisor.test.mjs create mode 100644 test/channels/dingtalk/device-auth.test.mjs create mode 100644 test/channels/dingtalk/dingtalk-api.test.mjs create mode 100644 test/channels/dingtalk/dingtalk-bridge.test.mjs create mode 100644 test/channels/dingtalk/dingtalk-card-stream.test.mjs create mode 100644 test/channels/dingtalk/dingtalk-controller.test.mjs create mode 100644 test/channels/dingtalk/dingtalk-runtime.test.mjs create mode 100644 test/channels/dingtalk/harness-client.test.mjs create mode 100644 test/channels/dingtalk/plugin-host.test.mjs create mode 100644 test/channels/dingtalk/production.test.mjs create mode 100644 test/channels/dingtalk/rpc.test.mjs create mode 100644 test/channels/dingtalk/state-store.test.mjs create mode 100644 test/channels/feishu/bridge.test.mjs create mode 100644 test/channels/feishu/client-api.test.mjs create mode 100644 test/channels/feishu/config.test.mjs create mode 100644 test/channels/feishu/connection-supervisor.test.mjs create mode 100644 test/channels/feishu/feishu-app.test.mjs create mode 100644 test/channels/feishu/feishu-channel.test.mjs create mode 100644 test/channels/feishu/feishu-runtime.test.mjs create mode 100644 test/channels/feishu/harness-client.test.mjs create mode 100644 test/channels/feishu/message-utils.test.mjs create mode 100644 test/channels/feishu/multi-bot-controller.test.mjs create mode 100644 test/channels/feishu/plugin-config-store.test.mjs create mode 100644 test/channels/feishu/plugin-controller.test.mjs create mode 100644 test/channels/feishu/plugin-host.test.mjs create mode 100644 test/channels/feishu/registration-manager.test.mjs create mode 100644 test/channels/feishu/state-store.test.mjs create mode 100644 test/channels/weixin/connection-supervisor.test.mjs create mode 100644 test/channels/weixin/harness-client.test.mjs create mode 100644 test/channels/weixin/plugin-host.test.mjs create mode 100644 test/channels/weixin/stores.test.mjs create mode 100644 test/channels/weixin/weixin-api.test.mjs create mode 100644 test/channels/weixin/weixin-bridge.test.mjs create mode 100644 test/channels/weixin/weixin-controller.test.mjs create mode 100644 test/channels/weixin/weixin-runtime.test.mjs diff --git a/.gitignore b/.gitignore index 46eee51..013b8d8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ !.env.example *.tgz .DS_Store +.idea/ diff --git a/README.md b/README.md index b418a37..62c1262 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ npx -y github:xmanrui/dsh-im install ## 设计 - Harness 中只注册一个「IM机器人」设置页; +- 飞书、微信和钉钉的 Host、客户端与运行时源码都在本仓库维护,不依赖外部 `dsh-feishu`、`dsh-weixin` 或 `dsh-dingtalk` 插件; - 左侧使用渠道 Logo 切换微信、飞书和钉钉,不使用启用/停用开关; - 三个渠道保持独立的 RPC、凭据、连接监督和会话映射; - 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret` 或原始 staff ID。 @@ -78,6 +79,7 @@ For DingTalk, scan with an account that belongs to an enterprise or organization ## Design - Registers a single **IM Bot** settings page in Harness. +- Maintains the Feishu, WeChat, and DingTalk Host, client, and runtime sources in this repository without external `dsh-feishu`, `dsh-weixin`, or `dsh-dingtalk` plugin dependencies. - Uses channel logos for WeChat, Feishu, and DingTalk 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`, or raw staff IDs. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 825cef9..de912d5 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,7 +1,11 @@ # Third-party notices -This package composes [`@xmanrui/dsh-feishu`](https://github.com/xmanrui/dsh-feishu) at commit `aad650feabadd511241aa58b236d64273d5e397f`, [`@xmanrui/dsh-weixin`](https://github.com/xmanrui/dsh-weixin) at commit `76d076771b2c84fb4c5598c2344d486695eda080`, and [`@xmanrui/dsh-dingtalk`](https://github.com/xmanrui/dsh-dingtalk) at commit `05f5a319b52ef7f0952baf5d603ebc144657262a`, all under the MIT License. +The Weixin iLink request format, QR-login states, and message fields are adapted from Tencent's [`openclaw-weixin`](https://github.com/Tencent/openclaw-weixin) project at commit `cef0bfc390393f716903e16d50408118047f87e0` (package version 2.4.6), licensed under the MIT License and copyright Tencent. -The channel client components are adapted from those projects and preserve their security model: secrets remain in the DeepSeek Harness Host credential provider and never enter the browser-facing RPC response. +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, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. Each is licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队. + +This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, Feishu, or DingTalk. The WeChat mark uses 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. diff --git a/lib/client.js b/lib/client.js index ec02abc..ca7c2be 100644 --- a/lib/client.js +++ b/lib/client.js @@ -42,7 +42,7 @@ __export(index_exports, { module.exports = __toCommonJS(index_exports); var React5 = __toESM(require("react"), 1); -// node_modules/@xmanrui/dsh-dingtalk/plugin-src/client/api.js +// plugin-src/client/channels/dingtalk/api.js var DINGTALK_RPC_CHANNEL = "/dingtalk"; var DINGTALK_ENDPOINTS = Object.freeze({ status: "connection.status", @@ -221,10 +221,10 @@ function formatRemaining(milliseconds) { return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } -// node_modules/@xmanrui/dsh-dingtalk/plugin-src/client/index.js +// plugin-src/client/channels/dingtalk/index.js var React = __toESM(require("react"), 1); -// node_modules/@xmanrui/dsh-dingtalk/plugin-src/client/styles.js +// plugin-src/client/channels/dingtalk/styles.js var DINGTALK_STYLE_ID = "xmanrui-dsh-dingtalk-settings"; var CSS = String.raw` .ddt-page { @@ -367,7 +367,7 @@ function installDingtalkStyles() { return () => style.remove(); } -// node_modules/@xmanrui/dsh-dingtalk/plugin-src/client/index.js +// plugin-src/client/channels/dingtalk/index.js var h = React.createElement; var ACTIVE_PROVISION_STATES = /* @__PURE__ */ new Set(["pending", "scanned", "authorizing", "creating", "connecting"]); function DingtalkIcon({ size = 28 }) { diff --git a/lib/index.js b/lib/index.js index 449864e..8f559ad 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,13 +1,7979 @@ +// plugin-src/host/channels/dingtalk/production.mjs +import { unlink as unlink3 } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +// src/channels/dingtalk/config-store.mjs +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +var EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) }); +var STORED_BOT_KEYS = /* @__PURE__ */ new Set(["clientId", "secretRef", "approvedSenders"]); +function cleanString(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function digest(value) { + return createHash("sha256").update(value).digest("hex"); +} +function safeBotId(value) { + const id = cleanString(value); + return id && /^dt_[a-f0-9]{24}$/.test(id) ? id : null; +} +function safeSecretRef(value) { + const ref = cleanString(value); + return ref && /^DSH_DINGTALK_BOT_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null; +} +function safeSenderKey(value) { + const key = cleanString(value); + return key && /^dt_sender_[a-f0-9]{32}$/.test(key) ? key : null; +} +function normalizeApprovedSender(value) { + const record = typeof value === "string" ? { staffId: value } : value; + if (!record || typeof record !== "object" || Array.isArray(record)) return null; + const senderKey = safeSenderKey(record.senderKey); + const staffId = cleanString(record.staffId); + if (!senderKey || !staffId) return null; + return Object.freeze({ + senderKey, + staffId, + displayName: cleanString(record.displayName), + approvedAt: cleanString(record.approvedAt) + }); +} +function normalizeApprovedSenders(value) { + if (!Array.isArray(value)) return null; + const senders = value.map(normalizeApprovedSender); + if (senders.some((sender) => sender === null)) return null; + const ids = /* @__PURE__ */ new Set(); + const keys = /* @__PURE__ */ new Set(); + for (const sender of senders) { + if (ids.has(sender.staffId) || keys.has(sender.senderKey)) return null; + ids.add(sender.staffId); + keys.add(sender.senderKey); + } + return Object.freeze(senders); +} +function deriveDingtalkBotIdentity(clientId) { + const value = cleanString(clientId); + if (!value) throw new TypeError("clientId is required"); + const valueDigest = digest(value).slice(0, 24); + return Object.freeze({ + botId: `dt_${valueDigest}`, + secretRef: `DSH_DINGTALK_BOT_SECRET_${valueDigest.toUpperCase()}` + }); +} +function deriveDingtalkSenderKey() { + return `dt_sender_${randomUUID().replaceAll("-", "")}`; +} +function maskDingtalkSenderId(staffId) { + const value = cleanString(staffId); + if (!value) return "\u9489\u9489\u7528\u6237"; + return "\u8EAB\u4EFD\u5DF2\u9690\u85CF"; +} +function maskDingtalkClientId(clientId) { + const value = cleanString(clientId); + if (!value) return "\u9489\u9489\u673A\u5668\u4EBA"; + if (value.length <= 8) return `${value.slice(0, 2)}\u2022\u2022\u2022\u2022`; + return `${value.slice(0, 4)}\u2022\u2022\u2022\u2022${value.slice(-4)}`; +} +function normalizeBot(value, { stored = false } = {}) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + if ("clientSecret" in value || "client_secret" in value || "deviceCode" in value) return null; + if (stored && Object.keys(value).some((key) => !STORED_BOT_KEYS.has(key))) return null; + const clientId = cleanString(value.clientId); + const secretRef = safeSecretRef(value.secretRef); + const approvedSenders = normalizeApprovedSenders(value.approvedSenders ?? []); + if (!clientId || !secretRef || !approvedSenders) return null; + const identity = deriveDingtalkBotIdentity(clientId); + if (identity.secretRef !== secretRef) return null; + const suppliedBotId = value.botId === void 0 ? identity.botId : safeBotId(value.botId); + if (suppliedBotId !== identity.botId) return null; + return Object.freeze({ + botId: identity.botId, + clientId, + secretRef, + approvedSenders + }); +} +function normalizeDocument(value) { + if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null; + const bots = value.bots.map((bot) => normalizeBot(bot, { stored: true })); + if (bots.some((bot) => bot === null)) return null; + const botIds = /* @__PURE__ */ new Set(); + const clientIds = /* @__PURE__ */ new Set(); + const secretRefs = /* @__PURE__ */ new Set(); + for (const bot of bots) { + if (botIds.has(bot.botId) || clientIds.has(bot.clientId) || secretRefs.has(bot.secretRef)) { + return null; + } + botIds.add(bot.botId); + clientIds.add(bot.clientId); + secretRefs.add(bot.secretRef); + } + return Object.freeze({ version: 1, bots: Object.freeze(bots) }); +} +function storedDocument(document) { + return { + version: 1, + bots: document.bots.map((bot) => ({ + clientId: bot.clientId, + secretRef: bot.secretRef, + approvedSenders: bot.approvedSenders.map((sender) => ({ + senderKey: sender.senderKey, + staffId: sender.staffId, + displayName: sender.displayName, + approvedAt: sender.approvedAt + })) + })) + }; +} +var DingtalkConfigStore = class { + #path; + #value = EMPTY_DOCUMENT; + #writeQueue = Promise.resolve(); + /** @param {string} path Absolute or process-relative configuration file path. */ + constructor(path) { + if (!cleanString(path)) throw new TypeError("config path is required"); + this.#path = path; + } + /** @returns {Promise} Loaded store. */ + async load() { + try { + const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, "utf8"))); + if (!normalized) throw new Error("dsh-dingtalk config contains invalid bot data"); + this.#value = normalized; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + this.#value = EMPTY_DOCUMENT; + } + return this; + } + /** @returns {Array} Cloned bot configurations with derived bot IDs. */ + list() { + return structuredClone(this.#value.bots); + } + /** @param {string} botId Derived bot ID. @returns {object|null} Bot configuration. */ + get(botId) { + const found = this.#value.bots.find((bot) => bot.botId === botId); + return found ? structuredClone(found) : null; + } + /** @param {string} clientId DingTalk client ID. @returns {object|null} Bot configuration. */ + getByClientId(clientId) { + const found = this.#value.bots.find((bot) => bot.clientId === clientId); + return found ? structuredClone(found) : null; + } + /** @param {object} value Bot configuration without a client secret. @returns {Promise} Saved config. */ + async save(value) { + const normalized = normalizeBot(value); + if (!normalized) throw new Error("Refusing to persist invalid dsh-dingtalk bot data"); + return this.#mutate((bots) => { + const collision = bots.find( + (bot) => (bot.clientId === normalized.clientId || bot.secretRef === normalized.secretRef) && bot.botId !== normalized.botId + ); + if (collision) throw new Error("Duplicate DingTalk bot identity"); + const index = bots.findIndex((bot) => bot.botId === normalized.botId); + if (index === -1) bots.push(normalized); + else bots[index] = normalized; + return structuredClone(normalized); + }); + } + /** @param {string} botId Derived bot ID. @returns {Promise} Removed config. */ + async remove(botId) { + if (!safeBotId(botId)) throw new TypeError("Invalid DingTalk 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); + }); + } + /** Removes the configuration file and resets the in-memory store. */ + 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(() => 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 mkdir(dirname(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(storedDocument(document), null, 2)} +`, { + encoding: "utf8", + flag: "wx", + mode: 384 + }); + await rename(temporary, this.#path); + } catch (error) { + try { + await unlink(temporary); + } catch (cleanupError) { + if (cleanupError?.code !== "ENOENT") throw new AggregateError([error, cleanupError]); + } + throw error; + } + } +}; + +// src/channels/dingtalk/device-auth.mjs +var DEFAULT_REGISTRATION_BASE_URL = "https://oapi.dingtalk.com"; +var REGISTRATION_SOURCE = "DING_DWS_CLAW"; +function cleanString2(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function positiveNumber(value, fallback) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : fallback; +} +function normalizeBaseUrl(value) { + let url; + try { + url = new URL(cleanString2(value) ?? DEFAULT_REGISTRATION_BASE_URL); + } catch { + throw new TypeError("DingTalk registration base URL must be a valid HTTPS URL"); + } + const isDingtalkHost2 = url.hostname === "dingtalk.com" || url.hostname.endsWith(".dingtalk.com"); + if (url.protocol !== "https:" || url.port || !isDingtalkHost2 || url.username || url.password || url.search || url.hash) { + throw new TypeError("DingTalk registration base URL must be a valid HTTPS URL"); + } + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.href.replace(/\/$/, ""); +} +function readNow(clock) { + const value = typeof clock?.now === "function" ? clock.now() : clock(); + if (!Number.isFinite(value)) throw new TypeError("clock must return a finite timestamp"); + return value; +} +function assertRecord(value, action) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new DingtalkDeviceAuthError( + "invalid-response", + `DingTalk ${action} returned an invalid response`, + action + ); + } + if (Number(value.errcode) !== 0) { + throw new DingtalkDeviceAuthError( + "api-error", + `DingTalk ${action} request was rejected`, + action + ); + } + return value; +} +var DingtalkDeviceAuthError = class extends Error { + /** + * @param {string} code Stable failure code. + * @param {string} message Safe diagnostic that does not include response credentials. + * @param {string} action Registration stage that failed. + * @param {{cause?: unknown}} [options] Optional underlying error. + */ + constructor(code, message, action, options = {}) { + super(message, options); + this.name = "DingtalkDeviceAuthError"; + this.code = code; + this.action = action; + } +}; +var DingtalkDeviceAuth = class { + #fetch; + #clock; + #baseUrl; + #timeoutMs; + /** + * @param {{fetch?: typeof globalThis.fetch, clock?: {now(): number}|(()=>number), baseUrl?: string, timeoutMs?: number}} [options] + * Device-registration dependencies. + */ + constructor({ + fetch: fetch2 = globalThis.fetch, + clock = Date, + baseUrl = DEFAULT_REGISTRATION_BASE_URL, + timeoutMs = 15e3 + } = {}) { + if (typeof fetch2 !== "function") throw new TypeError("fetch is required"); + if (typeof clock !== "function" && typeof clock?.now !== "function") { + throw new TypeError("clock must be a function or expose now()"); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new TypeError("timeoutMs must be a positive number"); + } + this.#fetch = fetch2; + this.#clock = clock; + this.#baseUrl = normalizeBaseUrl(baseUrl); + this.#timeoutMs = timeoutMs; + } + /** + * Starts a QR registration and returns the host-only device code with QR metadata. + * @param {{signal?: AbortSignal}} [options] Optional cancellation signal. + * @returns {Promise} Device registration details. + */ + async start({ signal } = {}) { + const initialized = await this.#post( + "/app/registration/init", + { source: REGISTRATION_SOURCE }, + "initialization", + signal + ); + const nonce = cleanString2(initialized.nonce); + if (!nonce) { + throw new DingtalkDeviceAuthError( + "missing-nonce", + "DingTalk registration initialization did not return a nonce", + "initialization" + ); + } + const begun = await this.#post( + "/app/registration/begin", + { nonce }, + "begin", + signal + ); + const deviceCode = cleanString2(begun.device_code); + const verificationUrl = cleanString2(begun.verification_uri_complete); + if (!deviceCode || !verificationUrl) { + throw new DingtalkDeviceAuthError( + "incomplete-registration", + "DingTalk registration did not return complete QR metadata", + "begin" + ); + } + const expiresInSeconds = positiveNumber(begun.expires_in, 7200); + const pollIntervalMs = positiveNumber(begun.interval, 5) * 1e3; + return Object.freeze({ + deviceCode, + verificationUrl, + verificationUri: cleanString2(begun.verification_uri), + userCode: cleanString2(begun.user_code), + expiresAt: readNow(this.#clock) + expiresInSeconds * 1e3, + pollIntervalMs + }); + } + /** + * Polls one registration attempt. + * @param {{deviceCode: string, signal?: AbortSignal}|string} request Host-only device code. + * @returns {Promise} Normalized registration state and credentials on success. + */ + async poll(request) { + const deviceCode = cleanString2(typeof request === "string" ? request : request?.deviceCode); + const signal = typeof request === "object" ? request?.signal : void 0; + if (!deviceCode) throw new TypeError("deviceCode is required"); + const response = await this.#post( + "/app/registration/poll", + { device_code: deviceCode }, + "poll", + signal + ); + const rawStatus = cleanString2(response.status)?.toUpperCase(); + const status = ["WAITING", "SUCCESS", "FAIL", "EXPIRED"].includes(rawStatus) ? rawStatus : "UNKNOWN"; + return Object.freeze({ + status, + clientId: cleanString2(response.client_id), + clientSecret: cleanString2(response.client_secret), + failReason: cleanString2(response.fail_reason) + }); + } + async #post(path, body, action, signal) { + let response; + const timeoutSignal = AbortSignal.timeout(this.#timeoutMs); + const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + try { + response = await this.#fetch(`${this.#baseUrl}${path}`, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json" + }, + body: JSON.stringify(body), + redirect: "error", + signal: requestSignal + }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + if (timeoutSignal.aborted) { + throw new DingtalkDeviceAuthError( + "timeout", + `DingTalk ${action} request timed out`, + action, + { cause: error } + ); + } + if (error?.name === "AbortError") throw error; + throw new DingtalkDeviceAuthError( + "network-error", + `DingTalk ${action} request could not be completed`, + action, + { cause: error } + ); + } + if (!response || response.ok === false || typeof response.json !== "function") { + throw new DingtalkDeviceAuthError( + "http-error", + `DingTalk ${action} request failed`, + action + ); + } + let value; + try { + value = await response.json(); + } catch (error) { + throw new DingtalkDeviceAuthError( + "invalid-json", + `DingTalk ${action} returned invalid JSON`, + action, + { cause: error } + ); + } + return assertRecord(value, action); + } +}; + +// src/channels/dingtalk/dingtalk-controller.mjs +import { randomUUID as randomUUID2 } from "node:crypto"; +var ACTIVE_ATTEMPT_STATES = /* @__PURE__ */ new Set(["starting", "pending", "connecting"]); +var TERMINAL_ATTEMPT_STATES = /* @__PURE__ */ new Set(["connected", "expired", "failed", "cancelled"]); +function cleanString3(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function safeError(code, message) { + return Object.freeze({ code, message }); +} +function nowFrom(clock) { + return typeof clock?.now === "function" ? clock.now() : clock(); +} +function isoNow(clock) { + return new Date(nowFrom(clock)).toISOString(); +} +function abortError() { + return new DOMException("DingTalk provisioning was cancelled", "AbortError"); +} +function publicAttempt(record) { + if (!record) return null; + return { + attemptId: record.id, + status: record.state, + ...record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}, + ...record.expiresAt ? { expiresAt: record.expiresAt } : {}, + ...record.pollIntervalMs ? { pollIntervalMs: record.pollIntervalMs } : {}, + ...record.botId ? { botId: record.botId } : {}, + ...record.alreadyConnected ? { alreadyConnected: true } : {}, + ...record.error ? { error: structuredClone(record.error) } : {} + }; +} +function runtimeStatus(runtime) { + if (!runtime) return {}; + const value = typeof runtime.status === "function" ? runtime.status() : runtime.status; + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} +function isRuntimeConnected(runtime, status) { + if (!runtime) return false; + if (status.connected === false || status.ready === false) return false; + const state = cleanString3( + status.dingtalkStreamState ?? status.dingtalkConnectionState ?? status.connectionState ?? status.state + )?.toLowerCase(); + if (["failed", "error", "offline", "disconnected", "stopped"].includes(state)) return false; + return status.connected === true || status.ready === true || state === "connected" || state === "ready"; +} +function normalizePendingSender(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const staffId = cleanString3(value.staffId ?? value.senderStaffId ?? value.senderId); + if (!staffId) return null; + const suppliedRequestId = cleanString3(value.requestId); + const opaqueRequestId = suppliedRequestId && /^ding_sender_[A-Za-z0-9_-]{1,100}$/.test(suppliedRequestId) && !suppliedRequestId.includes(staffId) ? suppliedRequestId : null; + if (!opaqueRequestId) return null; + return { + requestId: opaqueRequestId, + staffId, + displayName: cleanString3(value.displayName ?? value.senderName ?? value.senderNick) ?? "\u9489\u9489\u7528\u6237", + requestedAt: cleanString3(value.requestedAt) + }; +} +function internalPendingSenders(status) { + if (!Array.isArray(status.pendingSenders)) return []; + const seen = /* @__PURE__ */ new Set(); + const senders = []; + for (const value of status.pendingSenders) { + const sender = normalizePendingSender(value); + if (!sender || seen.has(sender.staffId)) continue; + seen.add(sender.staffId); + senders.push(sender); + } + return senders; +} +function publicPendingSender(sender) { + return { + requestId: sender.requestId, + displayName: sender.displayName, + senderIdMasked: maskDingtalkSenderId(sender.staffId), + requestedAt: sender.requestedAt + }; +} +function publicApprovedSender(sender) { + return { + senderKey: sender.senderKey, + displayName: cleanString3(sender.displayName) ?? "\u9489\u9489\u7528\u6237", + senderIdMasked: maskDingtalkSenderId(sender.staffId), + approvedAt: cleanString3(sender.approvedAt) + }; +} +var DingtalkController = class { + #deviceAuth; + #credentials; + #configStore; + #createRuntime; + #deleteState; + #logger; + #clock; + #runtimes = /* @__PURE__ */ new Map(); + #errors = /* @__PURE__ */ new Map(); + #attempts = /* @__PURE__ */ new Map(); + #activeAttemptId = null; + #transitions = /* @__PURE__ */ new Map(); + #revision = 0; + #closed = false; + /** + * @param {object} options Controller dependencies. + * @param {object} options.deviceAuth Host-only DingTalk device auth client. + * @param {object} options.credentials DSH credential provider. + * @param {object} options.configStore Loaded DingTalk config store. + * @param {Function} options.createRuntime Runtime factory. + * @param {Function} [options.deleteState] Per-bot state cleanup callback. + * @param {Console} [options.logger] Host logger. + * @param {{now(): number}|(()=>number)} [options.clock] Injectable clock. + */ + constructor({ + deviceAuth, + credentials, + configStore, + createRuntime, + deleteState = async () => { + }, + logger = console, + clock = Date + }) { + if (!deviceAuth || typeof deviceAuth.start !== "function" || typeof deviceAuth.poll !== "function") { + throw new TypeError("DingtalkController requires a DingTalk device auth client"); + } + if (!credentials || typeof credentials.resolve !== "function" || typeof credentials.set !== "function" || typeof credentials.unset !== "function") { + throw new TypeError("DingtalkController requires the DSH credential provider"); + } + if (!configStore || typeof configStore.list !== "function" || typeof configStore.get !== "function" || typeof configStore.getByClientId !== "function" || typeof configStore.save !== "function" || typeof configStore.remove !== "function") { + throw new TypeError("DingtalkController requires a loaded config store"); + } + if (typeof createRuntime !== "function") throw new TypeError("createRuntime is required"); + if (typeof deleteState !== "function") throw new TypeError("deleteState must be a function"); + if (typeof clock !== "function" && typeof clock?.now !== "function") { + throw new TypeError("clock must be a function or expose now()"); + } + this.#deviceAuth = deviceAuth; + this.#credentials = credentials; + this.#configStore = configStore; + this.#createRuntime = createRuntime; + this.#deleteState = deleteState; + this.#logger = logger; + this.#clock = clock; + } + /** Starts all configured DingTalk runtimes whose secrets are available. */ + async initialize() { + if (this.#closed) return this.status(); + for (const config of this.#configStore.list()) { + const current = this.#runtimes.get(config.botId); + try { + if (isRuntimeConnected(current, runtimeStatus(current))) continue; + } catch { + } + await this.#withBotTransition(config.botId, async () => { + const latest = this.#configStore.get(config.botId); + if (!latest || this.#closed) return; + const clientSecret = await this.#resolveSecret(latest.secretRef); + if (!clientSecret) { + this.#errors.set( + latest.botId, + safeError("missing-secret", "\u9489\u9489\u673A\u5668\u4EBA\u51ED\u636E\u7F3A\u5931\uFF0C\u8BF7\u79FB\u9664\u540E\u91CD\u65B0\u626B\u7801\u3002") + ); + this.#touch(); + return; + } + try { + await this.#startRuntime(latest, clientSecret); + this.#errors.delete(latest.botId); + } catch { + this.#errors.set( + latest.botId, + safeError("connection-failed", "\u9489\u9489\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002") + ); + this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`); + } + this.#touch(); + }); + } + return this.status(); + } + /** Starts one DingTalk QR registration, cancelling any prior active attempt. */ + async startProvisioning({ signal } = {}) { + if (this.#closed) throw new Error("dsh-dingtalk controller is closed"); + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + const record = { + id: randomUUID2(), + state: "starting", + controller: new AbortController(), + deviceCode: null, + verificationUrl: null, + expiresAt: null, + pollIntervalMs: null, + pollTask: null, + botId: null, + alreadyConnected: false, + error: null + }; + this.#attempts.set(record.id, record); + this.#activeAttemptId = record.id; + this.#touch(); + const abortFromRequest = () => record.controller.abort(signal?.reason); + if (signal?.aborted) abortFromRequest(); + else signal?.addEventListener("abort", abortFromRequest, { once: true }); + try { + const begun = await this.#deviceAuth.start({ signal: record.controller.signal }); + this.#assertAttemptActive(record); + record.deviceCode = cleanString3(begun.deviceCode); + record.verificationUrl = cleanString3(begun.verificationUrl); + record.expiresAt = Number(begun.expiresAt); + record.pollIntervalMs = Number(begun.pollIntervalMs); + if (!record.deviceCode || !record.verificationUrl || !Number.isFinite(record.expiresAt) || !Number.isFinite(record.pollIntervalMs) || record.pollIntervalMs <= 0) { + throw new Error("DingTalk device auth returned incomplete registration metadata"); + } + record.state = "pending"; + this.#touch(); + return publicAttempt(record); + } catch (error) { + if (record.controller.signal.aborted || error?.name === "AbortError") { + record.state = "cancelled"; + record.error = safeError("cancelled", "\u626B\u7801\u63A5\u5165\u5DF2\u53D6\u6D88\u3002"); + } else { + record.state = "failed"; + record.error = safeError("qr-start-failed", "\u65E0\u6CD5\u751F\u6210\u9489\u9489\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + if (record.state === "failed") throw error; + return publicAttempt(record); + } finally { + signal?.removeEventListener("abort", abortFromRequest); + } + } + /** Polls one QR registration without exposing its device code or returned secret. */ + async registrationStatus(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record) return null; + if (TERMINAL_ATTEMPT_STATES.has(record.state) || record.state === "starting") { + return publicAttempt(record); + } + if (nowFrom(this.#clock) >= record.expiresAt) { + record.state = "expired"; + record.error = safeError("expired", "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + return publicAttempt(record); + } + if (!record.pollTask) { + const task = this.#pollRegistration(record).finally(() => { + if (record.pollTask === task) record.pollTask = null; + }); + record.pollTask = task; + } + await record.pollTask; + return publicAttempt(record); + } + /** Cancels an active QR registration. */ + async cancelProvisioning(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record) return null; + if (!TERMINAL_ATTEMPT_STATES.has(record.state)) { + record.controller.abort(); + await record.pollTask?.catch(() => void 0); + if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = "cancelled"; + record.error ??= safeError("cancelled", "\u626B\u7801\u63A5\u5165\u5DF2\u53D6\u6D88\u3002"); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + return publicAttempt(record); + } + /** Replaces one bot runtime using its stored credential. */ + async reconnectBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error("Unknown DingTalk bot"); + await this.#withBotTransition(botId, async () => { + const clientSecret = await this.#resolveSecret(config.secretRef); + if (!clientSecret) throw new Error("The DingTalk client secret is missing"); + try { + await this.#startRuntime(config, clientSecret); + this.#errors.delete(botId); + } catch (error) { + this.#errors.set( + botId, + safeError("connection-failed", "\u9489\u9489\u8FDE\u63A5\u4ECD\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002") + ); + throw error; + } finally { + this.#touch(); + } + }); + return this.status(); + } + /** Removes one bot, its secret, runtime, and local conversation state. */ + async deleteBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error("Unknown DingTalk bot"); + await this.#withBotTransition(botId, async () => { + const previousSecret = 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 (cleanString3(previousSecret?.value)) { + await this.#credentials.set(config.secretRef, previousSecret.value).catch(() => void 0); + await this.#startRuntime(config, previousSecret.value).catch(() => void 0); + } + throw new Error("Unable to remove the DingTalk bot safely.", { cause: error }); + } + try { + await this.#deleteState({ botId, config }); + } catch { + this.#logger.warn?.(`[dsh-dingtalk] bot ${botId} state cleanup failed`); + } + this.#errors.delete(botId); + this.#touch(); + }); + return this.status(); + } + /** Approves one opaque pending-sender request for a bot. */ + async approveSender(botId, requestId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error("Unknown DingTalk bot"); + const runtime = this.#runtimes.get(botId); + const direct = typeof runtime?.pendingSender === "function" ? normalizePendingSender(runtime.pendingSender(requestId)) : null; + const pending = internalPendingSenders(runtimeStatus(runtime)); + const sender = direct?.requestId === requestId ? direct : pending.find((candidate) => candidate.requestId === requestId); + if (!sender) throw new Error("Unknown DingTalk sender approval request"); + if (config.approvedSenders.some((approved) => approved.staffId === sender.staffId)) { + return this.status(); + } + const updated = { + ...config, + approvedSenders: [ + ...config.approvedSenders, + { + senderKey: deriveDingtalkSenderKey(), + staffId: sender.staffId, + displayName: sender.displayName, + approvedAt: isoNow(this.#clock) + } + ] + }; + await this.#saveAndRestart(config, updated); + return this.status(); + } + /** Revokes one approved sender by its browser-safe sender key. */ + async revokeSender(botId, senderKey) { + const config = this.#configStore.get(botId); + if (!config) throw new Error("Unknown DingTalk bot"); + const index = config.approvedSenders.findIndex( + (sender) => sender.senderKey === senderKey + ); + if (index === -1) throw new Error("Unknown approved DingTalk sender"); + const approvedSenders = [...config.approvedSenders]; + approvedSenders.splice(index, 1); + await this.#saveAndRestart(config, { ...config, approvedSenders }); + return this.status(); + } + /** Returns browser-safe bot, health, and sender-approval state. */ + status() { + const bots = this.#configStore.list().map((config) => { + const runtime = this.#runtimes.get(config.botId); + let currentStatus = {}; + try { + currentStatus = runtimeStatus(runtime); + } catch { + currentStatus = { state: "error" }; + } + const connected = isRuntimeConnected(runtime, currentStatus); + const accountError = this.#errors.get(config.botId); + const state = connected ? "connected" : accountError ? "error" : "offline"; + const approvedIds = new Set(config.approvedSenders.map((sender) => sender.staffId)); + const pending = internalPendingSenders(currentStatus).filter((sender) => !approvedIds.has(sender.staffId)).map(publicPendingSender); + return { + botId: config.botId, + state, + connected, + configured: true, + bot: { + name: "\u9489\u9489\u673A\u5668\u4EBA", + clientIdMasked: maskDingtalkClientId(config.clientId) + }, + health: { + status: connected ? "healthy" : accountError ? "error" : "offline", + summary: connected ? "\u9489\u9489 Stream \u6D88\u606F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : accountError?.message ?? "\u9489\u9489\u6D88\u606F\u8FDE\u63A5\u5F53\u524D\u79BB\u7EBF", + lastCheckedAt: currentStatus.lastCheckedAt ?? null + }, + stats: { + messagesReceived: Number(currentStatus.messagesReceived) || 0, + messagesReplied: Number(currentStatus.messagesReplied) || 0 + }, + senders: { + pending, + approved: config.approvedSenders.map(publicApprovedSender) + }, + error: accountError ? structuredClone(accountError) : 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) } : {} + }; + } + /** Cancels provisioning and stops every bot runtime. */ + 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))); + await Promise.allSettled([...this.#transitions.values()]); + await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); + } + async #pollRegistration(record) { + try { + this.#assertAttemptActive(record); + const response = await this.#deviceAuth.poll({ + deviceCode: record.deviceCode, + signal: record.controller.signal + }); + this.#assertAttemptActive(record); + const state = cleanString3(response.status)?.toUpperCase(); + if (state === "WAITING") { + record.state = "pending"; + record.error = null; + } else if (state === "SUCCESS") { + const clientId = cleanString3(response.clientId); + const clientSecret = cleanString3(response.clientSecret); + if (!clientId || !clientSecret) throw new Error("DingTalk returned incomplete credentials"); + record.state = "connecting"; + record.error = null; + this.#touch(); + const activation = await this.#activateBot(record, { clientId, clientSecret }); + record.botId = activation.botId; + record.alreadyConnected = activation.alreadyConnected; + record.state = "connected"; + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + } else if (state === "EXPIRED") { + record.state = "expired"; + record.error = safeError("expired", "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + } else if (state === "FAIL") { + record.state = "failed"; + record.error = safeError("authorization-failed", "\u9489\u9489\u672A\u5B8C\u6210\u673A\u5668\u4EBA\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u626B\u7801\u3002"); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + } else { + record.state = "pending"; + record.error = safeError("poll-pending", "\u9489\u9489\u6388\u6743\u72B6\u6001\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u6B63\u5728\u91CD\u8BD5\u3002"); + } + } catch (error) { + if (record.controller.signal.aborted || error?.name === "AbortError") { + record.state = "cancelled"; + record.error = safeError("cancelled", "\u626B\u7801\u63A5\u5165\u5DF2\u53D6\u6D88\u3002"); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + } else if (record.state === "connecting") { + record.state = "failed"; + record.error = safeError( + "activation-failed", + "\u9489\u9489\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u5B89\u5168\u4FDD\u5B58\u63A5\u5165\u914D\u7F6E\u3002" + ); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#logger.error?.("[dsh-dingtalk] bot activation failed"); + } else { + record.state = "pending"; + record.error = safeError("poll-failed", "\u9489\u9489\u6388\u6743\u67E5\u8BE2\u6682\u65F6\u5931\u8D25\uFF0C\u6B63\u5728\u91CD\u8BD5\u3002"); + } + } finally { + this.#touch(); + this.#pruneAttempts(); + } + } + async #activateBot(record, { clientId, clientSecret }) { + const identity = deriveDingtalkBotIdentity(clientId); + const previousConfig = this.#configStore.getByClientId(clientId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0); + const config = { + botId: identity.botId, + clientId, + secretRef: identity.secretRef, + approvedSenders: previousConfig?.approvedSenders ?? [] + }; + return this.#withBotTransition(identity.botId, async () => { + const rollback = async () => { + await this.#stopRuntime(identity.botId); + if (previousConfig) await this.#configStore.save(previousConfig).catch(() => void 0); + else if (this.#configStore.get(identity.botId)) { + await this.#configStore.remove(identity.botId).catch(() => void 0); + } + await this.#restoreCredential(identity.secretRef, previousSecret); + if (previousConfig && cleanString3(previousSecret?.value)) { + await this.#startRuntime(previousConfig, previousSecret.value).catch(() => void 0); + } + }; + await this.#credentials.set(identity.secretRef, clientSecret); + try { + this.#assertAttemptActive(record); + await this.#configStore.save(config); + this.#assertAttemptActive(record); + } catch (error) { + await rollback(); + throw error; + } + try { + await this.#startRuntime(config, clientSecret); + this.#assertAttemptActive(record); + this.#errors.delete(identity.botId); + } catch (error) { + if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) { + await rollback(); + throw abortError(); + } + this.#errors.set( + identity.botId, + safeError("connection-failed", "\u9489\u9489\u5DF2\u63A5\u5165\uFF0C\u4F46\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002") + ); + this.#logger.warn?.("[dsh-dingtalk] authorized bot saved but its connection is not ready"); + } + return { botId: identity.botId, alreadyConnected: Boolean(previousConfig) }; + }); + } + async #saveAndRestart(previousConfig, nextConfig) { + return this.#withBotTransition(previousConfig.botId, async () => { + const clientSecret = await this.#resolveSecret(previousConfig.secretRef); + if (!clientSecret) throw new Error("The DingTalk client secret is missing"); + await this.#configStore.save(nextConfig); + try { + await this.#startRuntime(nextConfig, clientSecret); + this.#errors.delete(previousConfig.botId); + } catch (error) { + await this.#configStore.save(previousConfig).catch(() => void 0); + await this.#startRuntime(previousConfig, clientSecret).catch(() => void 0); + this.#errors.set( + previousConfig.botId, + safeError("connection-failed", "\u9489\u9489\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002") + ); + throw error; + } finally { + this.#touch(); + } + }); + } + async #startRuntime(config, clientSecret) { + if (this.#closed) throw abortError(); + await this.#stopRuntime(config.botId); + if (this.#closed) throw abortError(); + const runtime = await this.#createRuntime({ + botId: config.botId, + config: structuredClone(config), + clientSecret + }); + if (!runtime || typeof runtime.start !== "function" || typeof runtime.stop !== "function") { + throw new TypeError("createRuntime returned an invalid DingTalk runtime"); + } + if (this.#closed) { + await runtime.stop().catch(() => void 0); + throw abortError(); + } + this.#runtimes.set(config.botId, runtime); + try { + await runtime.start(); + if (this.#closed) { + await runtime.stop().catch(() => void 0); + throw abortError(); + } + } catch (error) { + if (this.#runtimes.get(config.botId) === runtime) this.#runtimes.delete(config.botId); + await runtime.stop().catch(() => void 0); + throw error; + } + } + async #stopRuntime(botId) { + const runtime = this.#runtimes.get(botId); + this.#runtimes.delete(botId); + await runtime?.stop().catch(() => { + this.#logger.warn?.(`[dsh-dingtalk] bot ${botId} failed to stop cleanly`); + }); + } + async #resolveSecret(secretRef) { + const result = await this.#credentials.resolve(secretRef).catch(() => void 0); + return cleanString3(result?.value); + } + async #restoreCredential(secretRef, previous) { + try { + if (cleanString3(previous?.value)) await this.#credentials.set(secretRef, previous.value); + else await this.#credentials.unset(secretRef); + } catch { + this.#logger.error?.(`[dsh-dingtalk] failed to restore credential ${secretRef}`); + } + } + #assertAttemptActive(record) { + if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) throw abortError(); + } + #withBotTransition(botId, operation) { + if (this.#closed) return Promise.reject(new Error("dsh-dingtalk controller is closed")); + 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; + } + #pruneAttempts() { + for (const [id, record] of this.#attempts) { + if (id !== this.#activeAttemptId && TERMINAL_ATTEMPT_STATES.has(record.state) && this.#attempts.size > 16) { + this.#attempts.delete(id); + } + } + } + #touch() { + this.#revision += 1; + } +}; + +// src/channels/dingtalk/dingtalk-api.mjs +import { randomUUID as randomUUID3 } from "node:crypto"; +var DINGTALK_REGISTRATION_BASE_URL = "https://oapi.dingtalk.com/"; +var DINGTALK_API_BASE_URL = "https://api.dingtalk.com/"; +var DINGTALK_REGISTRATION_SOURCE = "DING_DWS_CLAW"; +var DINGTALK_AI_CARD_TEMPLATE_ID = "02fcf2f4-5e02-4a85-b672-46d1f715543e.schema"; +var DEFAULT_TIMEOUT_MS = 15e3; +var REGISTRATION_STATUSES = /* @__PURE__ */ new Set(["WAITING", "SUCCESS", "FAIL", "EXPIRED"]); +var DingtalkApiError = class extends Error { + constructor(code, message, options = {}) { + super(message, options); + this.name = "DingtalkApiError"; + this.code = code; + this.status = options.status; + } +}; +function nonEmptyString(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function isDingtalkHost(hostname) { + const normalized = hostname.toLowerCase().replace(/\.$/, ""); + return normalized === "dingtalk.com" || normalized.endsWith(".dingtalk.com"); +} +function normalizeTrustedUrl(value, { label, requireSubdomain = true } = {}) { + let url; + try { + url = new URL(value); + } catch { + throw new DingtalkApiError("invalid-url", `${label ?? "\u9489\u9489\u670D\u52A1"}\u8FD4\u56DE\u4E86\u65E0\u6548\u5730\u5740\u3002`); + } + const normalizedHost = url.hostname.toLowerCase().replace(/\.$/, ""); + const trustedHost = requireSubdomain ? normalizedHost !== "dingtalk.com" && isDingtalkHost(normalizedHost) : isDingtalkHost(normalizedHost); + if (url.protocol !== "https:" || !trustedHost || url.port && url.port !== "443") { + throw new DingtalkApiError("untrusted-url", `${label ?? "\u9489\u9489\u670D\u52A1"}\u5730\u5740\u4E0D\u53D7\u4FE1\u4EFB\u3002`); + } + if (url.username || url.password) { + throw new DingtalkApiError("untrusted-url", `${label ?? "\u9489\u9489\u670D\u52A1"}\u5730\u5740\u4E0D\u53D7\u4FE1\u4EFB\u3002`); + } + return url; +} +function normalizeDingtalkSessionWebhook(value) { + const text = nonEmptyString(value); + if (!text) throw new DingtalkApiError("invalid-session-webhook", "\u9489\u9489\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u56DE\u590D\u5730\u5740\u3002"); + const url = normalizeTrustedUrl(text, { label: "\u9489\u9489\u56DE\u590D", requireSubdomain: false }); + url.hash = ""; + return url.toString(); +} +function splitDingtalkText(value, maxChars = 4e3) { + const text = typeof value === "string" ? value.trim() : ""; + if (!text) return []; + if (!Number.isInteger(maxChars) || maxChars < 1) throw new TypeError("maxChars must be a positive integer"); + if (text.length <= maxChars) return [text]; + const chunks = []; + let remaining = text; + while (remaining.length > maxChars) { + let splitAt = remaining.lastIndexOf("\n", maxChars); + if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n+/, ""); + } + if (remaining) chunks.push(remaining); + return chunks; +} +function abortError2(signal) { + if (signal?.reason instanceof Error) return signal.reason; + return new DOMException("The operation was aborted", "AbortError"); +} +function abortableDelay(ms, signal) { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve4, reject) => { + if (signal?.aborted) { + reject(abortError2(signal)); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve4(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(abortError2(signal)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} +async function requestJson(fetchImpl, url, { + body, + signal, + timeoutMs = DEFAULT_TIMEOUT_MS, + headers = {}, + method = "POST", + action = "request" +} = {}) { + const controller = new AbortController(); + let timedOut = false; + const onAbort = () => controller.abort(signal?.reason); + if (signal?.aborted) throw abortError2(signal); + signal?.addEventListener("abort", onAbort, { once: true }); + const timer = timeoutMs > 0 ? setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs) : null; + try { + const response = await fetchImpl(url, { + method, + redirect: "error", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body ?? {}), + signal: controller.signal + }); + if (!response.ok) { + throw new DingtalkApiError( + "http-error", + `\u9489\u9489\u670D\u52A1\u8BF7\u6C42\u5931\u8D25\uFF08HTTP ${response.status}\uFF09\u3002`, + { status: response.status } + ); + } + try { + return await response.json(); + } catch (error) { + throw new DingtalkApiError("invalid-response", "\u9489\u9489\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u89E3\u6790\u7684\u54CD\u5E94\u3002", { cause: error }); + } + } catch (error) { + if (signal?.aborted) throw abortError2(signal); + if (timedOut) throw new DingtalkApiError("timeout", "\u9489\u9489\u670D\u52A1\u8BF7\u6C42\u8D85\u65F6\u3002", { cause: error }); + if (error instanceof DingtalkApiError) throw error; + throw new DingtalkApiError("network-error", `\u6682\u65F6\u65E0\u6CD5\u5B8C\u6210\u9489\u9489${action}\u8BF7\u6C42\u3002`, { cause: error }); + } finally { + if (timer) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } +} +function normalizeCardTarget(target) { + if (target?.type === "user") { + const userId = nonEmptyString(target.userId); + if (userId) return { type: "user", userId }; + } + if (target?.type === "group") { + const openConversationId = nonEmptyString(target.openConversationId); + if (openConversationId) return { type: "group", openConversationId }; + } + throw new TypeError("DingTalk AI Card target is invalid"); +} +function cardData(text, flowStatus) { + return { + cardParamMap: { + flowStatus, + msgContent: normalizeDingtalkCardMarkdown(text), + staticMsgContent: "", + sys_full_json_obj: JSON.stringify({ order: ["msgContent"] }), + config: JSON.stringify({ autoLayout: true }) + } + }; +} +function cardDeliverBody(cardInstanceId, target, robotCode) { + const base = { outTrackId: cardInstanceId, userIdType: 1 }; + if (target.type === "group") { + return { + ...base, + openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`, + imGroupOpenDeliverModel: { robotCode } + }; + } + return { + ...base, + openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`, + imRobotOpenDeliverModel: { + spaceType: "IM_ROBOT", + robotCode, + extension: { dynamicSummary: "true" } + } + }; +} +function normalizeDingtalkCardMarkdown(value) { + const text = typeof value === "string" ? value.replace(/\r\n?/g, "\n") : ""; + const lines = text.split("\n"); + let inCodeBlock = false; + return lines.map((line, index) => { + const fenced = /^\s{0,3}```/.test(line); + const currentInCodeBlock = inCodeBlock; + if (fenced) inCodeBlock = !inCodeBlock; + if (index === lines.length - 1) return line; + if (currentInCodeBlock || fenced || inCodeBlock || !line || !lines[index + 1]) return `${line} +`; + if (/^\s{0,3}(?:[-*+] |\d+[.)] |#{1,6} |\||> )/.test(lines[index + 1])) return `${line} +`; + return `${line}
`; + }).join(""); +} +function assertRegistrationOk(value, action) { + if (!value || typeof value !== "object" || value.errcode !== 0) { + throw new DingtalkApiError( + "registration-rejected", + `\u9489\u9489\u626B\u7801${action}\u5931\u8D25\u3002` + ); + } + return value; +} +function positiveNumber2(value, fallback) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : fallback; +} +function createDingtalkApi({ + fetchImpl = fetch, + registrationBaseUrl = process.env.DINGTALK_REGISTRATION_BASE_URL || DINGTALK_REGISTRATION_BASE_URL, + registrationSource = process.env.DINGTALK_REGISTRATION_SOURCE || DINGTALK_REGISTRATION_SOURCE, + now = () => Date.now(), + cardMinIntervalMs = 50, + cardBackoffMs = 1e3, + delay: delay2 = abortableDelay +} = {}) { + if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function"); + if (typeof now !== "function") throw new TypeError("now must be a function"); + if (!Number.isFinite(cardMinIntervalMs) || cardMinIntervalMs < 0) { + throw new TypeError("cardMinIntervalMs must be a non-negative number"); + } + if (!Number.isFinite(cardBackoffMs) || cardBackoffMs < 0) { + throw new TypeError("cardBackoffMs must be a non-negative number"); + } + if (typeof delay2 !== "function") throw new TypeError("delay must be a function"); + const registrationBase = normalizeTrustedUrl(registrationBaseUrl, { + label: "\u9489\u9489\u6CE8\u518C\u670D\u52A1", + requireSubdomain: false + }); + const apiBase = new URL(DINGTALK_API_BASE_URL); + const source = nonEmptyString(registrationSource); + if (!source) throw new TypeError("registrationSource is required"); + const tokenCache = /* @__PURE__ */ new Map(); + const tokenRequests = /* @__PURE__ */ new Map(); + let cardSlotTail = Promise.resolve(); + let nextCardRequestAt = 0; + const endpoint = (base, pathname) => new URL(pathname.replace(/^\//, ""), base); + async function accessToken({ clientId, clientSecret, signal }) { + const appKey = nonEmptyString(clientId); + const appSecret = nonEmptyString(clientSecret); + if (!appKey || !appSecret) throw new TypeError("clientId and clientSecret are required"); + const cached = tokenCache.get(appKey); + if (cached && cached.expiresAt > now()) return cached.token; + if (tokenRequests.has(appKey)) return tokenRequests.get(appKey); + const request = (async () => { + const value = await requestJson(fetchImpl, endpoint(apiBase, "v1.0/oauth2/accessToken"), { + body: { appKey, appSecret }, + signal, + action: "\u9274\u6743" + }); + const token = nonEmptyString(value?.accessToken); + if (!token) throw new DingtalkApiError("invalid-access-token", "\u9489\u9489\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u8BBF\u95EE\u4EE4\u724C\u3002"); + const expiresInSeconds = positiveNumber2(value?.expireIn ?? value?.expiresIn, 7200); + const refreshAfterMs = Math.max(1e3, (expiresInSeconds - 60) * 1e3); + tokenCache.set(appKey, { token, expiresAt: now() + refreshAfterMs }); + return token; + })().finally(() => tokenRequests.delete(appKey)); + tokenRequests.set(appKey, request); + return request; + } + function acquireCardRequestSlot(signal) { + const acquire = async () => { + const waitMs = Math.max(0, nextCardRequestAt - now()); + if (waitMs > 0) await delay2(waitMs, signal); + nextCardRequestAt = Math.max(nextCardRequestAt, now()) + cardMinIntervalMs; + }; + const slot = cardSlotTail.then(acquire, acquire); + cardSlotTail = slot.catch(() => void 0); + return slot; + } + async function cardRequest(pathname, options) { + await acquireCardRequestSlot(options.signal); + try { + return await requestJson(fetchImpl, endpoint(apiBase, pathname), options); + } catch (error) { + if (!(error instanceof DingtalkApiError) || error.status !== 403) throw error; + await delay2(cardBackoffMs, options.signal); + await acquireCardRequestSlot(options.signal); + return requestJson(fetchImpl, endpoint(apiBase, pathname), options); + } + } + async function failCard({ clientId, clientSecret, cardInstanceId, text, signal }) { + const instanceId = nonEmptyString(cardInstanceId); + const content = nonEmptyString(text); + if (!instanceId) throw new TypeError("cardInstanceId is required"); + if (!content) throw new TypeError("text is required"); + const token = await accessToken({ clientId, clientSecret, signal }); + const headers = { "x-acs-dingtalk-access-token": token }; + const requests = [ + cardRequest("v1.0/card/streaming", { + method: "PUT", + body: { + outTrackId: instanceId, + guid: randomUUID3(), + key: "msgContent", + content: normalizeDingtalkCardMarkdown(content), + isFull: true, + isFinalize: false, + isError: true + }, + headers, + signal, + action: "AI Card \u5931\u8D25\u6536\u53E3" + }), + cardRequest("v1.0/card/instances", { + method: "PUT", + body: { + outTrackId: instanceId, + cardData: cardData(content, "5"), + cardUpdateOptions: { updateCardDataByKey: true } + }, + headers, + signal, + action: "AI Card \u5931\u8D25\u72B6\u6001" + }) + ]; + const results = await Promise.allSettled(requests); + if (results.every(({ status }) => status === "rejected")) throw results[0].reason; + return true; + } + return Object.freeze({ + async beginRegistration({ signal } = {}) { + const initialized = assertRegistrationOk(await requestJson( + fetchImpl, + endpoint(registrationBase, "app/registration/init"), + { body: { source }, signal, action: "\u521D\u59CB\u5316" } + ), "\u521D\u59CB\u5316"); + const nonce = nonEmptyString(initialized.nonce); + if (!nonce) throw new DingtalkApiError("invalid-registration", "\u9489\u9489\u626B\u7801\u521D\u59CB\u5316\u7F3A\u5C11 nonce\u3002"); + const begun = assertRegistrationOk(await requestJson( + fetchImpl, + endpoint(registrationBase, "app/registration/begin"), + { body: { nonce }, signal, action: "\u521B\u5EFA" } + ), "\u521B\u5EFA"); + const deviceCode = nonEmptyString(begun.device_code); + const verificationUriComplete = nonEmptyString(begun.verification_uri_complete); + if (!deviceCode || !verificationUriComplete) { + throw new DingtalkApiError("invalid-registration", "\u9489\u9489\u626B\u7801\u670D\u52A1\u8FD4\u56DE\u7684\u4FE1\u606F\u4E0D\u5B8C\u6574\u3002"); + } + const verificationUrl = normalizeTrustedUrl(verificationUriComplete, { + label: "\u9489\u9489\u626B\u7801", + requireSubdomain: false + }).toString(); + return { + deviceCode, + userCode: nonEmptyString(begun.user_code) ?? void 0, + verificationUri: nonEmptyString(begun.verification_uri) ?? void 0, + verificationUriComplete: verificationUrl, + expiresInSeconds: positiveNumber2(begun.expires_in, 7200), + intervalSeconds: positiveNumber2(begun.interval, 5) + }; + }, + async pollRegistration({ deviceCode, signal } = {}) { + const code = nonEmptyString(deviceCode); + if (!code) throw new TypeError("deviceCode is required"); + const polled = assertRegistrationOk(await requestJson( + fetchImpl, + endpoint(registrationBase, "app/registration/poll"), + { body: { device_code: code }, signal, action: "\u72B6\u6001\u67E5\u8BE2" } + ), "\u72B6\u6001\u67E5\u8BE2"); + const status = nonEmptyString(polled.status)?.toUpperCase(); + if (!status || !REGISTRATION_STATUSES.has(status)) { + throw new DingtalkApiError("invalid-registration-status", "\u9489\u9489\u626B\u7801\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u72B6\u6001\u3002"); + } + const result = { + status, + failReason: nonEmptyString(polled.fail_reason) ?? void 0 + }; + if (status === "SUCCESS") { + result.clientId = nonEmptyString(polled.client_id) ?? void 0; + result.clientSecret = nonEmptyString(polled.client_secret) ?? void 0; + if (!result.clientId || !result.clientSecret) { + throw new DingtalkApiError("missing-credentials", "\u9489\u9489\u626B\u7801\u5DF2\u786E\u8BA4\uFF0C\u4F46\u6CA1\u6709\u8FD4\u56DE\u673A\u5668\u4EBA\u51ED\u636E\u3002"); + } + } + return result; + }, + accessToken, + async createAiCard({ clientId, clientSecret, target, initialText, signal }) { + const appKey = nonEmptyString(clientId); + const appSecret = nonEmptyString(clientSecret); + const content = nonEmptyString(initialText); + if (!appKey || !appSecret) throw new TypeError("clientId and clientSecret are required"); + if (!content) throw new TypeError("initialText is required"); + const normalizedTarget = normalizeCardTarget(target); + const token = await accessToken({ clientId: appKey, clientSecret: appSecret, signal }); + const cardInstanceId = `dsh_${randomUUID3()}`; + const headers = { "x-acs-dingtalk-access-token": token }; + let delivered = false; + try { + await cardRequest("v1.0/card/instances", { + body: { + cardTemplateId: DINGTALK_AI_CARD_TEMPLATE_ID, + outTrackId: cardInstanceId, + cardData: { + cardParamMap: { config: JSON.stringify({ autoLayout: true }) } + }, + callbackType: "STREAM", + imGroupOpenSpaceModel: { supportForward: true }, + imRobotOpenSpaceModel: { supportForward: true } + }, + headers, + signal, + action: "AI Card \u521B\u5EFA" + }); + await cardRequest("v1.0/card/instances/deliver", { + body: cardDeliverBody(cardInstanceId, normalizedTarget, appKey), + headers, + signal, + action: "AI Card \u6295\u653E" + }); + delivered = true; + await cardRequest("v1.0/card/instances", { + method: "PUT", + body: { outTrackId: cardInstanceId, cardData: cardData(content, "2") }, + headers, + signal, + action: "AI Card \u542F\u52A8" + }); + await cardRequest("v1.0/card/streaming", { + method: "PUT", + body: { + outTrackId: cardInstanceId, + guid: randomUUID3(), + key: "msgContent", + content: normalizeDingtalkCardMarkdown(content).replace(/\n+$/, ""), + isFull: true, + isFinalize: false, + isError: false + }, + headers, + signal, + action: "AI Card \u542F\u52A8" + }); + } catch (error) { + if (delivered) { + const cleanupSignal = AbortSignal.timeout(5e3); + await failCard({ + clientId: appKey, + clientSecret: appSecret, + cardInstanceId, + text: "\u6D88\u606F\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002", + signal: cleanupSignal + }).catch(() => void 0); + } + throw error; + } + return { cardInstanceId }; + }, + async updateAiCard({ clientId, clientSecret, cardInstanceId, text, signal }) { + const instanceId = nonEmptyString(cardInstanceId); + const content = nonEmptyString(text); + if (!instanceId) throw new TypeError("cardInstanceId is required"); + if (!content) throw new TypeError("text is required"); + const token = await accessToken({ clientId, clientSecret, signal }); + await cardRequest("v1.0/card/streaming", { + method: "PUT", + body: { + outTrackId: instanceId, + guid: randomUUID3(), + key: "msgContent", + content: normalizeDingtalkCardMarkdown(content).replace(/\n+$/, ""), + isFull: true, + isFinalize: false, + isError: false + }, + headers: { "x-acs-dingtalk-access-token": token }, + signal, + action: "AI Card \u66F4\u65B0" + }); + return true; + }, + async finishAiCard({ clientId, clientSecret, cardInstanceId, text, signal }) { + const instanceId = nonEmptyString(cardInstanceId); + const content = nonEmptyString(text); + if (!instanceId) throw new TypeError("cardInstanceId is required"); + if (!content) throw new TypeError("text is required"); + const token = await accessToken({ clientId, clientSecret, signal }); + const headers = { "x-acs-dingtalk-access-token": token }; + const normalizedContent = normalizeDingtalkCardMarkdown(content); + await cardRequest("v1.0/card/streaming", { + method: "PUT", + body: { + outTrackId: instanceId, + guid: randomUUID3(), + key: "msgContent", + content: normalizedContent, + isFull: true, + isFinalize: true, + isError: false + }, + headers, + signal, + action: "AI Card \u5B8C\u6210" + }); + let completed = true; + const completionRequest = { + method: "PUT", + body: { + outTrackId: instanceId, + cardData: cardData(content, "3"), + cardUpdateOptions: { updateCardDataByKey: true } + }, + headers, + signal, + action: "AI Card \u6536\u53E3" + }; + try { + await cardRequest("v1.0/card/instances", completionRequest); + } catch { + try { + await cardRequest("v1.0/card/instances", completionRequest); + } catch { + completed = false; + } + } + return { delivered: true, completed }; + }, + failAiCard: failCard, + async sendText({ clientId, clientSecret, sessionWebhook, text, signal }) { + const content = nonEmptyString(text); + if (!content) throw new TypeError("text is required"); + const webhook = normalizeDingtalkSessionWebhook(sessionWebhook); + const token = await accessToken({ clientId, clientSecret, signal }); + const response = await requestJson(fetchImpl, webhook, { + body: { msgtype: "text", text: { content } }, + headers: { "x-acs-dingtalk-access-token": token }, + signal, + action: "\u6D88\u606F\u56DE\u590D" + }); + if (response?.errcode !== void 0 && response.errcode !== 0 || response?.code !== void 0 && response.code !== 0) { + throw new DingtalkApiError("send-rejected", "\u9489\u9489\u670D\u52A1\u62D2\u7EDD\u4E86\u56DE\u590D\u6D88\u606F\u3002"); + } + return true; + }, + clearAccessToken(clientId) { + const appKey = nonEmptyString(clientId); + if (appKey) tokenCache.delete(appKey); + } + }); +} + +// src/channels/dingtalk/dingtalk-card-stream.mjs +var DEFAULT_UPDATE_INTERVAL_MS = 500; +var FAILURE_TEXT = "\u6D88\u606F\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"; +function requiredText(value, name2) { + if (typeof value !== "string") throw new TypeError(`${name2} must be a string`); + return value; +} +function requiredCredential(value, name2) { + if (typeof value !== "string" || !value.trim()) { + throw new TypeError(`${name2} is required`); + } + return value.trim(); +} +function createDingTalkCardStream({ + api, + clientId, + clientSecret, + target, + signal, + logger = console, + updateIntervalMs = DEFAULT_UPDATE_INTERVAL_MS, + clock = () => Date.now(), + timer = { + setTimeout: (callback, delay2) => globalThis.setTimeout(callback, delay2), + clearTimeout: (handle) => globalThis.clearTimeout(handle) + } +} = {}) { + if (!api || typeof api.createAiCard !== "function" || typeof api.updateAiCard !== "function" || typeof api.finishAiCard !== "function") { + throw new TypeError("DingTalk AI Card API is required"); + } + const normalizedClientId = requiredCredential(clientId, "clientId"); + const normalizedClientSecret = requiredCredential(clientSecret, "clientSecret"); + if (target === void 0 || target === null) throw new TypeError("target is required"); + if (!Number.isFinite(updateIntervalMs) || updateIntervalMs < 0) { + throw new TypeError("updateIntervalMs must be a non-negative number"); + } + if (typeof clock !== "function") throw new TypeError("clock must be a function"); + if (typeof timer?.setTimeout !== "function" || typeof timer?.clearTimeout !== "function") { + throw new TypeError("timer must provide setTimeout and clearTimeout"); + } + const readClock = () => { + const value = clock(); + if (!Number.isFinite(value)) throw new TypeError("clock must return a finite timestamp"); + return value; + }; + readClock(); + let phase = signal?.aborted ? "aborted" : "idle"; + let cardRequest = null; + let pendingText = null; + let scheduledUpdate = null; + let updateWorker = null; + let finishPromise = null; + let cleanupPromise = null; + let lastUpdateAt = 0; + const clearScheduledUpdate = () => { + if (scheduledUpdate === null) return; + timer.clearTimeout(scheduledUpdate); + scheduledUpdate = null; + }; + const removeAbortListener = () => signal?.removeEventListener("abort", onAbort); + const close = (nextPhase) => { + phase = nextPhase; + pendingText = null; + clearScheduledUpdate(); + removeAbortListener(); + }; + const cleanupCard = () => { + if (!cardRequest || typeof api.failAiCard !== "function") return Promise.resolve(false); + if (!cleanupPromise) { + cleanupPromise = api.failAiCard({ + ...cardRequest, + text: FAILURE_TEXT, + signal: AbortSignal.timeout(5e3) + }).then( + () => true, + () => false + ); + } + return cleanupPromise; + }; + const fail = (operation) => { + if (phase === "failed" || phase === "finished" || phase === "aborted") return; + void cleanupCard(); + close("failed"); + logger?.error?.(`[dsh-dingtalk] AI Card ${operation} failed`); + }; + function onAbort() { + if (phase === "finished" || phase === "failed" || phase === "aborted") return; + void cleanupCard(); + close("aborted"); + } + if (phase !== "aborted") signal?.addEventListener("abort", onAbort, { once: true }); + const launchUpdate = () => { + if (phase !== "active" || updateWorker || pendingText === null) return; + const delay2 = Math.max(0, lastUpdateAt + updateIntervalMs - readClock()); + if (delay2 > 0) { + scheduledUpdate = timer.setTimeout(() => { + scheduledUpdate = null; + launchUpdate(); + }, delay2); + return; + } + const text = pendingText; + pendingText = null; + updateWorker = (async () => { + try { + await api.updateAiCard({ ...cardRequest, text, finished: false }); + lastUpdateAt = readClock(); + } catch { + if (signal?.aborted || phase === "aborted") return; + fail("update"); + } + })().finally(() => { + updateWorker = null; + if (phase === "active" && pendingText !== null) launchUpdate(); + }); + }; + const start = async (initialText) => { + requiredText(initialText, "initialText"); + if (phase !== "idle") return false; + phase = "starting"; + try { + const created = await api.createAiCard({ + clientId: normalizedClientId, + clientSecret: normalizedClientSecret, + target, + initialText, + signal + }); + const cardInstanceId = typeof created?.cardInstanceId === "string" ? created.cardInstanceId.trim() : ""; + if (!cardInstanceId) throw new TypeError("DingTalk did not return a card instance id"); + cardRequest = Object.freeze({ + clientId: normalizedClientId, + clientSecret: normalizedClientSecret, + target, + cardInstanceId, + signal + }); + if (phase !== "starting") { + void cleanupCard(); + return false; + } + lastUpdateAt = readClock(); + phase = "active"; + return true; + } catch { + if (signal?.aborted || phase === "aborted") { + close("aborted"); + return false; + } + fail("creation"); + return false; + } + }; + const push = (progressText2) => { + requiredText(progressText2, "progressText"); + if (phase !== "active") return; + pendingText = progressText2; + if (!scheduledUpdate && !updateWorker) launchUpdate(); + }; + const finish = (finalText) => { + requiredText(finalText, "finalText"); + if (phase === "finished") return Promise.resolve(true); + if (phase === "finishing") return finishPromise; + if (phase !== "active") return Promise.resolve(false); + phase = "finishing"; + pendingText = null; + clearScheduledUpdate(); + const activeUpdate = updateWorker; + finishPromise = (async () => { + if (activeUpdate) await activeUpdate; + if (phase !== "finishing") return false; + try { + await api.finishAiCard({ ...cardRequest, text: finalText }); + if (phase !== "finishing") return false; + close("finished"); + return true; + } catch { + if (signal?.aborted || phase === "aborted") { + close("aborted"); + return false; + } + fail("finish"); + return false; + } + })(); + return finishPromise; + }; + return Object.freeze({ start, push, finish }); +} + +// src/channels/dingtalk/dingtalk-bridge.mjs +var CARD_INITIAL_TEXT = "\u5DF2\u8FDE\u63A5 DeepSeek Harness\uFF0C\u6B63\u5728\u601D\u8003\u2026"; +var CARD_ERROR_TEXT = "\u6D88\u606F\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"; +var HELP_TEXT = [ + "\u9489\u9489\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"); +function nonEmptyString2(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function senderStaffId(message) { + return nonEmptyString2(message?.senderStaffId) ?? nonEmptyString2(message?.senderId); +} +function conversationKey(message, sender) { + if (String(message?.conversationType) === "2") { + const conversationId = nonEmptyString2(message?.conversationId); + if (!conversationId) throw new Error("DingTalk group message has no conversation id"); + return `group:${conversationId}`; + } + return `p2p:${sender}`; +} +function cardTarget(message, sender) { + if (String(message?.conversationType) === "2") { + return { type: "group", openConversationId: nonEmptyString2(message?.conversationId) }; + } + return { type: "user", userId: sender }; +} +function progressText(update) { + if (update?.type === "text" && nonEmptyString2(update.text)) return update.text; + if (update?.type === "tool") { + if (update.name === "web_search") return "_\u6B63\u5728\u641C\u7D22\u7F51\u7EDC\u5E76\u6574\u7406\u4FE1\u606F\u2026_"; + return `_\u6B63\u5728\u4F7F\u7528 ${nonEmptyString2(update.name) ?? "\u5DE5\u5177"}\u2026_`; + } + return `_${nonEmptyString2(update?.text) ?? "\u6B63\u5728\u5904\u7406\u2026"}_`; +} +function ensureStats(status) { + status.stats ??= {}; + for (const key of ["messagesReceived", "messagesReplied", "messagesRejected", "messagesIgnored"]) { + status[key] ??= 0; + status.stats[key] = status[key]; + } + status.pendingSenders ??= []; +} +function increment(status, key) { + status[key] = (status[key] ?? 0) + 1; + status.stats ??= {}; + status.stats[key] = status[key]; +} +function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) { + return { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + messagesIgnored: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null, + pendingSenders: structuredClone(pendingSenders), + stats: { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + messagesIgnored: 0 + } + }; +} +var DingtalkHarnessBridge = class { + #api; + #clientId; + #clientSecret; + #harness; + #state; + #status; + #logger; + #replyTimeoutMs; + #maxMessageChars; + #signal; + #queues = /* @__PURE__ */ new Map(); + #acceptedMessageIds = /* @__PURE__ */ new Set(); + constructor({ + api, + clientId, + clientSecret, + harness, + state, + status = createDingtalkBridgeStatus(), + logger = console, + replyTimeoutMs = 6e5, + maxMessageChars = 4e3, + signal + }) { + if (!api || typeof api.sendText !== "function") throw new TypeError("DingTalk API is required"); + if (!nonEmptyString2(clientId) || !nonEmptyString2(clientSecret)) { + throw new TypeError("DingTalk app credentials are required"); + } + if (!harness || !state) throw new TypeError("Harness client and state store are required"); + this.#api = api; + this.#clientId = clientId.trim(); + this.#clientSecret = clientSecret.trim(); + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#maxMessageChars = maxMessageChars; + this.#signal = signal; + ensureStats(this.#status); + this.#refreshPendingSenders(); + } + get status() { + this.#refreshPendingSenders(); + return structuredClone(this.#status); + } + accept(message) { + if (this.#signal?.aborted) return Promise.resolve(); + const messageId = nonEmptyString2(message?.msgId); + const sender = senderStaffId(message); + if (!messageId || !sender || this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) return Promise.resolve(); + this.#acceptedMessageIds.add(messageId); + let key; + try { + key = conversationKey(message, sender); + } catch { + this.#acceptedMessageIds.delete(messageId); + increment(this.#status, "messagesRejected"); + this.#status.lastRejectedAt = (/* @__PURE__ */ new Date()).toISOString(); + return Promise.resolve(); + } + const previous = this.#queues.get(key) ?? Promise.resolve(); + const current = previous.catch(() => void 0).then(() => this.#process(message, messageId, sender, key)).finally(() => { + this.#acceptedMessageIds.delete(messageId); + 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 #process(message, messageId, sender, key) { + this.#signal?.throwIfAborted(); + if (this.#state.hasSeen(messageId)) return; + await this.#state.markSeen(messageId); + increment(this.#status, "messagesReceived"); + this.#status.lastMessageAt = (/* @__PURE__ */ new Date()).toISOString(); + if (String(message.conversationType) === "2" && message.isInAtList !== true) { + increment(this.#status, "messagesIgnored"); + return; + } + let sessionWebhook; + try { + sessionWebhook = normalizeDingtalkSessionWebhook(message.sessionWebhook); + } catch { + increment(this.#status, "messagesRejected"); + this.#status.lastRejectedAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.lastError = "\u9489\u9489\u6D88\u606F\u6CA1\u6709\u5B89\u5168\u7684\u56DE\u590D\u5730\u5740\u3002"; + return; + } + const text = message?.msgtype === "text" ? nonEmptyString2(message?.text?.content) : null; + let cardStream = null; + let cardStarted = false; + try { + if (!text) { + await this.#send(sessionWebhook, "\u76EE\u524D\u4EC5\u652F\u6301\u6587\u5B57\u6D88\u606F\u3002"); + return; + } + const command = text.toLowerCase(); + if (command === "/help") { + await this.#send(sessionWebhook, HELP_TEXT); + return; + } + if (command === "/status") { + await this.#harness.ensureRunning({ signal: this.#signal }); + await this.#send(sessionWebhook, "\u9489\u9489\u673A\u5668\u4EBA\u4E0E DeepSeek Harness \u8FDE\u63A5\u6B63\u5E38\u3002"); + return; + } + if (command === "/new") { + await this.#state.clearSession(key); + await this.#send(sessionWebhook, "\u5DF2\u5F00\u542F\u65B0\u4F1A\u8BDD\u3002\u8BF7\u53D1\u9001\u4F60\u7684\u95EE\u9898\u3002"); + return; + } + let sessionId = this.#state.sessionFor(key); + if (!sessionId || !await this.#harness.sessionExists(sessionId, { signal: this.#signal })) { + sessionId = await this.#harness.createSession({ signal: this.#signal }); + await this.#state.setSession(key, sessionId); + } + if (typeof this.#api.createAiCard === "function" && typeof this.#api.updateAiCard === "function" && typeof this.#api.finishAiCard === "function") { + cardStream = createDingTalkCardStream({ + api: this.#api, + clientId: this.#clientId, + clientSecret: this.#clientSecret, + target: cardTarget(message, sender), + signal: this.#signal, + logger: this.#logger + }); + cardStarted = await cardStream.start(CARD_INITIAL_TEXT); + } + const answer = await this.#harness.ask(sessionId, text, { + timeoutMs: this.#replyTimeoutMs, + signal: this.#signal, + onUpdate: cardStarted ? (update) => cardStream.push(progressText(update)) : void 0 + }); + const streamed = cardStarted && await cardStream.finish(answer); + if (!streamed) await this.#send(sessionWebhook, answer); + increment(this.#status, "messagesReplied"); + this.#status.lastReplyAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.lastError = null; + } catch { + if (this.#signal?.aborted) return; + this.#status.lastError = "\u9489\u9489\u6D88\u606F\u5904\u7406\u5931\u8D25\u3002"; + this.#logger.error?.("[dsh-dingtalk] failed to process an inbound message"); + try { + const streamed = cardStarted && await cardStream.finish(CARD_ERROR_TEXT); + if (!streamed) await this.#send(sessionWebhook, CARD_ERROR_TEXT); + } catch { + this.#logger.error?.("[dsh-dingtalk] failed to send the safe error reply"); + } + } + } + #refreshPendingSenders() { + if (typeof this.#state.pendingSenders === "function") { + this.#status.pendingSenders = this.#state.pendingSenders(); + } + } + async #send(sessionWebhook, text) { + for (const chunk of splitDingtalkText(text, this.#maxMessageChars)) { + this.#signal?.throwIfAborted(); + await this.#api.sendText({ + clientId: this.#clientId, + clientSecret: this.#clientSecret, + sessionWebhook, + text: chunk, + signal: this.#signal + }); + } + } +}; + +// src/channels/dingtalk/dingtalk-runtime.mjs +function nonEmptyString3(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function approvedSenderIds(config) { + const entries = Array.isArray(config?.approvedSenders) ? config.approvedSenders : config?.approvedSenders instanceof Set ? [...config.approvedSenders] : []; + return new Set(entries.map((entry) => nonEmptyString3( + typeof entry === "string" ? entry : entry?.staffId + )).filter(Boolean)); +} +function approvedSenderCount(config) { + return approvedSenderIds(config).size; +} +function streamIsOpen(client) { + return client?.connected === true || client?.socket?.readyState === 1; +} +function abortable(promise, signal) { + return new Promise((resolve4, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + const onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + Promise.resolve(promise).then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve4(value); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + } + ); + }); +} +async function waitForStreamOpen(client, pollIntervalMs, signal) { + while (true) { + signal?.throwIfAborted(); + if (streamIsOpen(client)) return; + await new Promise((resolve4, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve4(); + }, pollIntervalMs); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } +} +async function connectStream(client, timeoutMs, pollIntervalMs, signal) { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const connectSignal = AbortSignal.any([signal, timeoutSignal]); + let connectSettled = false; + const connectTask = Promise.resolve().then(() => client.connect()).finally(() => { + connectSettled = true; + }); + try { + await abortable(connectTask, connectSignal); + await waitForStreamOpen(client, pollIntervalMs, connectSignal); + } catch (error) { + if (connectSignal.aborted) { + if (!connectSettled) { + void connectTask.then(() => client.disconnect()).catch(() => void 0); + } + if (signal.aborted) throw signal.reason; + throw new Error(`DingTalk Stream handshake timed out after ${timeoutMs}ms`); + } + throw error; + } +} +async function defaultStreamFactory({ clientId, clientSecret }) { + const { DWClient, TOPIC_ROBOT } = await import("dingtalk-stream"); + return { + client: new DWClient({ + clientId, + clientSecret, + endpoint: "https://api.dingtalk.com", + autoReconnect: false, + keepAlive: true, + debug: false + }), + topic: TOPIC_ROBOT + }; +} +function createDingtalkRuntimeStatus({ + pendingSenders = [], + approvedSenders = 0 +} = {}) { + return { + startedAt: null, + ready: false, + dingtalkStreamState: "idle", + harnessReachable: false, + lastConnectedAt: null, + lastCheckedAt: null, + lastCallbackAt: null, + authorizationMode: "sender-staff-id-approval", + approvedSenderCount: approvedSenders, + ...createDingtalkBridgeStatus({ pendingSenders }) + }; +} +var DingtalkRuntime = class { + #config; + #clientSecret; + #harness; + #state; + #logger; + #replyTimeoutMs; + #maxMessageChars; + #connectTimeoutMs; + #connectPollIntervalMs; + #api; + #streamFactory; + #status; + #client = null; + #bridge = null; + #topic = null; + #starting = null; + #connectionMonitor = null; + #abortController = null; + #callbackTasks = /* @__PURE__ */ new Set(); + constructor({ + config, + clientSecret, + harness, + state, + logger = console, + replyTimeoutMs = 6e5, + maxMessageChars = 4e3, + connectTimeoutMs = 15e3, + connectPollIntervalMs = 25, + api = createDingtalkApi(), + streamFactory = defaultStreamFactory + }) { + if (!config || !nonEmptyString3(config.clientId) || !nonEmptyString3(clientSecret)) { + throw new TypeError("DingtalkRuntime requires app credentials"); + } + if (!harness || !state) throw new TypeError("DingtalkRuntime requires Harness and state"); + if (typeof streamFactory !== "function") throw new TypeError("streamFactory must be a function"); + this.#config = config; + this.#clientSecret = clientSecret.trim(); + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#maxMessageChars = maxMessageChars; + this.#connectTimeoutMs = connectTimeoutMs; + this.#connectPollIntervalMs = connectPollIntervalMs; + this.#api = api; + this.#streamFactory = streamFactory; + this.#status = createDingtalkRuntimeStatus({ + pendingSenders: this.#pendingSenders(), + approvedSenders: approvedSenderCount(config) + }); + } + get status() { + if (this.#bridge) { + const bridgeStatus = this.#bridge.status; + Object.assign(this.#status, bridgeStatus); + } else { + this.#status.pendingSenders = this.#pendingSenders(); + } + return structuredClone(this.#status); + } + pendingSender(requestId) { + return typeof this.#state.pendingSender === "function" ? this.#state.pendingSender(requestId) : null; + } + pendingSenders() { + return this.#pendingSenders(); + } + async start() { + if (this.#client && this.#status.ready) return this.status; + if (this.#starting) return this.#starting; + this.#starting = this.#start().finally(() => { + this.#starting = null; + }); + return this.#starting; + } + async #start() { + await this.stop(); + const abortController = new AbortController(); + this.#abortController = abortController; + const { signal } = abortController; + this.#status.startedAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.dingtalkStreamState = "connecting"; + this.#status.lastError = null; + try { + await this.#harness.ensureRunning({ signal }); + this.#status.harnessReachable = true; + if (typeof this.#state.removePendingSenderByStaffId === "function") { + for (const staffId of approvedSenderIds(this.#config)) { + await this.#state.removePendingSenderByStaffId(staffId); + } + this.#status.pendingSenders = this.#pendingSenders(); + } + this.#bridge = new DingtalkHarnessBridge({ + api: this.#api, + clientId: this.#config.clientId, + clientSecret: this.#clientSecret, + approvedSenders: this.#config.approvedSenders, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs, + maxMessageChars: this.#maxMessageChars, + signal + }); + const created = await this.#streamFactory({ + clientId: this.#config.clientId, + clientSecret: this.#clientSecret + }); + signal.throwIfAborted(); + this.#client = created?.client ?? created; + this.#topic = created?.topic ?? created?.TOPIC_ROBOT ?? "/v1.0/im/bot/messages/get"; + if (!this.#client || typeof this.#client.registerCallbackListener !== "function" || typeof this.#client.connect !== "function" || typeof this.#client.disconnect !== "function" || typeof this.#client.socketCallBackResponse !== "function") { + throw new TypeError("streamFactory returned an invalid DingTalk Stream client"); + } + const client = this.#client; + const bridge = this.#bridge; + client.registerCallbackListener(this.#topic, (response) => { + if (this.#client !== client || this.#bridge !== bridge) return; + const callbackMessageId = nonEmptyString3(response?.headers?.messageId); + if (callbackMessageId) { + try { + client.socketCallBackResponse(callbackMessageId, { success: true }); + } catch { + this.#logger.warn?.("[dsh-dingtalk] unable to acknowledge an inbound callback"); + } + } + const task = Promise.resolve().then(async () => { + if (this.#bridge !== bridge) return; + let message; + try { + message = typeof response?.data === "string" ? JSON.parse(response.data) : response?.data; + } catch { + this.#status.lastError = "\u9489\u9489\u6D88\u606F\u683C\u5F0F\u65E0\u6548\u3002"; + this.#logger.warn?.("[dsh-dingtalk] ignored an invalid callback payload"); + return; + } + if (!message || typeof message !== "object") return; + this.#status.lastCallbackAt = Date.now(); + await bridge.accept(message); + }).catch(() => { + if (signal.aborted || this.#bridge !== bridge) return; + this.#status.lastError = "\u9489\u9489\u6D88\u606F\u5904\u7406\u5931\u8D25\u3002"; + this.#logger.error?.("[dsh-dingtalk] callback processing failed"); + }).finally(() => this.#callbackTasks.delete(task)); + this.#callbackTasks.add(task); + }); + await connectStream( + client, + this.#connectTimeoutMs, + this.#connectPollIntervalMs, + signal + ); + this.#status.ready = true; + this.#status.dingtalkStreamState = "connected"; + this.#status.lastConnectedAt = Date.now(); + this.#status.lastCheckedAt = Date.now(); + this.#status.lastError = null; + this.#connectionMonitor = setInterval(() => { + const connected = streamIsOpen(client); + this.#status.ready = connected; + this.#status.dingtalkStreamState = connected ? "connected" : "reconnecting"; + this.#status.lastCheckedAt = Date.now(); + if (connected) this.#status.lastError = null; + }, 1e3); + this.#connectionMonitor.unref?.(); + return this.status; + } catch (error) { + const aborted = signal.aborted; + this.#status.ready = false; + this.#status.dingtalkStreamState = aborted ? "idle" : "failed"; + this.#status.lastError = aborted ? null : error?.message ?? String(error); + await this.stop({ preserveError: !aborted }); + throw error; + } + } + async stop({ preserveError = false } = {}) { + const lastError = preserveError ? this.#status.lastError : null; + const abortController = this.#abortController; + this.#abortController = null; + abortController?.abort(new DOMException("DingTalk runtime stopped", "AbortError")); + if (this.#connectionMonitor) clearInterval(this.#connectionMonitor); + this.#connectionMonitor = null; + this.#status.ready = false; + const client = this.#client; + this.#client = null; + this.#topic = null; + if (client) { + try { + await client.disconnect(); + } catch { + this.#logger.warn?.("[dsh-dingtalk] DingTalk Stream disconnect failed"); + } + } + await Promise.allSettled([...this.#callbackTasks]); + this.#callbackTasks.clear(); + if (this.#bridge) await this.#bridge.waitForIdle(); + this.#bridge = null; + this.#status.dingtalkStreamState = preserveError ? "failed" : "idle"; + this.#status.lastError = lastError; + return this.status; + } + #pendingSenders() { + return typeof this.#state.pendingSenders === "function" ? this.#state.pendingSenders() : []; + } +}; + +// src/channels/dingtalk/harness-client.mjs +import { spawn } from "node:child_process"; +import { randomUUID as randomUUID4 } from "node:crypto"; +function sleep(ms, signal) { + return new Promise((resolve4, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve4(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} +function assistantMessageText(event) { + return (event?.data?.message?.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim(); +} +var HarnessReplyTracker = class { + #promptRpcId; + #lastSeq; + #openTurn = null; + #targetTurn = null; + #stepText = /* @__PURE__ */ new Map(); + #latestText = ""; + #finished = false; + #reason = null; + constructor({ promptRpcId, afterSeq = -1 }) { + this.#promptRpcId = promptRpcId; + this.#lastSeq = afterSeq; + } + get finished() { + return this.#finished; + } + get answer() { + return this.#latestText.trim(); + } + get reason() { + return this.#reason; + } + consume(entries) { + let update = null; + const ordered = [...entries].map((entry) => entry?.event ?? entry).filter(Boolean).sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1)); + for (const event of ordered) { + const seq = event.seq ?? -1; + if (seq <= this.#lastSeq) continue; + this.#lastSeq = seq; + if (event.type === "turn/start") this.#openTurn = event.data?.turn ?? null; + if (event.type === "user/message" && event.data?.source?.rpcId === this.#promptRpcId) { + this.#targetTurn = this.#openTurn; + continue; + } + if (this.#targetTurn === null) continue; + if (event.type === "turn/end") { + if (event.data?.turn !== this.#targetTurn) continue; + this.#finished = true; + this.#reason = event.data?.reason ?? null; + this.#openTurn = null; + continue; + } + if (event.data?.turn !== this.#targetTurn) continue; + if (event.type === "assistant/chunk" && event.data?.chunk?.type === "text-delta") { + const step = event.data?.step ?? 0; + const index = event.data.chunk.index ?? 0; + const key = `${step}:${index}`; + this.#stepText.set(key, (this.#stepText.get(key) ?? "") + event.data.chunk.text); + const prefix = `${step}:`; + const text = [...this.#stepText.entries()].filter(([partKey]) => partKey.startsWith(prefix)).sort(([left], [right]) => Number(left.split(":")[1]) - Number(right.split(":")[1])).map(([, part]) => part).join("\n").trim(); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: "text", text }; + } + continue; + } + if (event.type === "assistant/message") { + const text = assistantMessageText(event); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: "text", text }; + } + continue; + } + if (event.type === "tool/call") { + update = { type: "tool", name: event.data?.name ?? "\u5DE5\u5177" }; + } else if (event.type === "tool/result") { + update = { type: "status", text: "\u6B63\u5728\u6574\u7406\u7ED3\u679C\u2026" }; + } + } + return update; + } +}; +var HarnessRpcError = class extends Error { + constructor(method, error) { + super(`${method}: ${error?.message ?? "unknown Harness RPC error"}`); + this.name = "HarnessRpcError"; + this.method = method; + this.code = error?.code ?? "internal"; + this.details = error?.details ?? {}; + } +}; +var HarnessClient = class { + #baseUrl; + #workspace; + #agentPreset; + #autostart; + #dshBin; + #fetch; + #managedProcess = null; + constructor({ + baseUrl, + workspace, + agentPreset = "standard", + autostart = false, + dshBin = "dsh", + fetchImpl = fetch + }) { + this.#baseUrl = new URL(baseUrl); + this.#workspace = workspace; + this.#agentPreset = agentPreset; + this.#autostart = autostart; + this.#dshBin = dshBin; + this.#fetch = fetchImpl; + } + async rpc(method, payload = {}, timeoutMs = 3e4, options = {}) { + const rpcId = options.rpcId ?? `dingtalk-${randomUUID4()}`; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; + const response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "client-request", rpcId, method, payload }), + signal + }); + if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`); + const body = await response.json(); + if (body?.type !== "server-response" || body?.rpcId !== rpcId) { + throw new Error(`Harness returned an invalid response for ${method}`); + } + if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error); + return body.result.value; + } + async health(options = {}) { + await this.rpc("host.describe", {}, 5e3, options); + return true; + } + async ensureRunning(options = {}) { + try { + return await this.health(options); + } catch (firstError) { + if (!this.#autostart) throw firstError; + } + if (!this.#managedProcess || this.#managedProcess.exitCode !== null) { + const port = this.#baseUrl.port || (this.#baseUrl.protocol === "https:" ? "443" : "80"); + this.#managedProcess = spawn(this.#dshBin, [ + "web", + "--host", + this.#baseUrl.hostname, + "--port", + port + ], { + cwd: this.#workspace, + env: process.env, + stdio: ["ignore", "inherit", "inherit"] + }); + this.#managedProcess.on("error", (error) => { + console.error("[dsh-dingtalk] failed to start Harness:", error.message); + }); + } + const deadline = Date.now() + 6e4; + let lastError; + while (Date.now() < deadline) { + await sleep(1e3, options.signal); + try { + return await this.health(options); + } catch (error) { + lastError = error; + } + } + throw new Error(`Harness did not become ready: ${lastError?.message ?? "timeout"}`); + } + async workspaceId(options = {}) { + const { items } = await this.rpc("workspace.list", {}, 3e4, options); + const existing = items.find((item) => item.path === this.#workspace); + if (existing) return existing.workspaceId; + const created = await this.rpc("workspace.create", { path: this.#workspace }, 3e4, options); + return created.workspace.workspaceId; + } + async createSession(options = {}) { + await this.ensureRunning(options); + const workspaceId = await this.workspaceId(options); + const created = await this.rpc("session.create", { + workspaceId, + agentPreset: this.#agentPreset + }, 3e4, options); + return created.sessionId; + } + async sessionExists(sessionId, options = {}) { + try { + await this.rpc("session.history", { sessionId, maxMessages: 1 }, 3e4, options); + return true; + } catch (error) { + if (error instanceof HarnessRpcError && error.code === "session-not-found") return false; + throw error; + } + } + async ask(sessionId, text, options = {}) { + if (typeof options === "number") options = { timeoutMs: options }; + const timeoutMs = options.timeoutMs ?? 6e5; + const signal = options.signal; + const onUpdate = typeof options.onUpdate === "function" ? options.onUpdate : null; + await this.ensureRunning({ signal }); + const before = await this.rpc( + "session.history", + { sessionId, maxMessages: 1 }, + 3e4, + { signal } + ); + const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1)); + const promptRpcId = `dingtalk-${randomUUID4()}`; + const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq }); + await this.rpc("session.prompt", { + sessionId, + mode: "queue", + content: [{ type: "text", text }], + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone + }, 3e4, { rpcId: promptRpcId, signal }); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(300, signal); + const history = await this.rpc( + "session.history", + { sessionId, maxMessages: 50 }, + 3e4, + { signal } + ); + const update = tracker.consume(history.events ?? []); + if (update && onUpdate) { + try { + await onUpdate(update); + } catch (error) { + console.warn("[dsh-dingtalk] ignored a progress update failure:", error.message); + } + } + if (!tracker.finished) continue; + if (tracker.answer) return tracker.answer; + throw new Error( + `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ""}` + ); + } + throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1e3)} seconds`); + } + stopManagedProcess() { + if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill("SIGTERM"); + } +}; + +// src/channels/dingtalk/state-store.mjs +import { randomUUID as randomUUID5 } from "node:crypto"; +import { mkdir as mkdir2, readFile as readFile2, rename as rename2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises"; +import { dirname as dirname2 } from "node:path"; +var EMPTY_STATE = Object.freeze({ + version: 1, + sessions: {}, + seenMessageIds: [], + pendingSenders: {} +}); +function nonEmptyString4(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function displayName(value) { + return (nonEmptyString4(value) ?? "\u9489\u9489\u7528\u6237").slice(0, 100); +} +function normalizePendingSender2(value, fallbackRequestId) { + if (!value || typeof value !== "object") return null; + const requestId = nonEmptyString4(value.requestId) ?? nonEmptyString4(fallbackRequestId); + const staffId = nonEmptyString4(value.staffId); + const requestedAt = nonEmptyString4(value.requestedAt) ?? nonEmptyString4(value.lastSeenAt); + const lastSeenAt = nonEmptyString4(value.lastSeenAt) ?? requestedAt; + if (!requestId || !staffId || !requestedAt || !lastSeenAt) return null; + return { + requestId, + staffId, + displayName: displayName(value.displayName ?? value.nick), + requestedAt, + lastSeenAt + }; +} +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)) { + const normalizedKey = nonEmptyString4(key); + const normalizedSession = nonEmptyString4(sessionId); + if (normalizedKey && normalizedSession) sessions[normalizedKey] = normalizedSession; + } + } + const pendingSenders = {}; + const entries = Array.isArray(value.pendingSenders) ? value.pendingSenders.map((entry) => [entry?.requestId, entry]) : Object.entries(value.pendingSenders && typeof value.pendingSenders === "object" ? value.pendingSenders : {}); + for (const [key, candidate] of entries) { + const pending = normalizePendingSender2(candidate, key); + if (!pending) continue; + const duplicate = Object.values(pendingSenders).find((entry) => entry.staffId === pending.staffId); + if (!duplicate || duplicate.lastSeenAt < pending.lastSeenAt) { + if (duplicate) delete pendingSenders[duplicate.requestId]; + pendingSenders[pending.requestId] = pending; + } + } + return { + version: 1, + sessions, + seenMessageIds: Array.isArray(value.seenMessageIds) ? [...new Set(value.seenMessageIds.map(nonEmptyString4).filter(Boolean))].slice(-1e3) : [], + pendingSenders + }; +} +var DingtalkStateStore = class { + #path; + #state = structuredClone(EMPTY_STATE); + #writeQueue = Promise.resolve(); + #idFactory; + #now; + constructor(path, { idFactory = randomUUID5, now = () => (/* @__PURE__ */ new Date()).toISOString() } = {}) { + if (!nonEmptyString4(path)) throw new TypeError("state path is required"); + if (typeof idFactory !== "function" || typeof now !== "function") { + throw new TypeError("idFactory and now must be functions"); + } + this.#path = path; + this.#idFactory = idFactory; + this.#now = now; + } + async load() { + try { + this.#state = normalizeState(JSON.parse(await readFile2(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) { + const normalizedKey = nonEmptyString4(key); + const normalizedSession = nonEmptyString4(sessionId); + if (!normalizedKey || !normalizedSession) throw new TypeError("key and sessionId are required"); + this.#state.sessions[normalizedKey] = normalizedSession; + await this.#persist(); + } + async clearSession(key) { + const normalizedKey = nonEmptyString4(key); + if (!normalizedKey || !(normalizedKey in this.#state.sessions)) return; + delete this.#state.sessions[normalizedKey]; + await this.#persist(); + } + hasSeen(messageId) { + const id = nonEmptyString4(messageId); + return Boolean(id && this.#state.seenMessageIds.includes(id)); + } + async markSeen(messageId) { + const id = nonEmptyString4(messageId); + if (!id) throw new TypeError("messageId is required"); + if (this.hasSeen(id)) return; + this.#state.seenMessageIds.push(id); + if (this.#state.seenMessageIds.length > 1e3) { + this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1e3); + } + await this.#persist(); + } + pendingSenders() { + return Object.values(this.#state.pendingSenders).sort((left, right) => left.requestedAt.localeCompare(right.requestedAt)).map((entry) => structuredClone(entry)); + } + pendingSender(requestId) { + const id = nonEmptyString4(requestId); + const entry = id ? this.#state.pendingSenders[id] : null; + return entry ? structuredClone(entry) : null; + } + async recordPendingSender(staffIdOrEntry, name2, seenAt) { + const input = staffIdOrEntry && typeof staffIdOrEntry === "object" ? staffIdOrEntry : { staffId: staffIdOrEntry, displayName: name2, lastSeenAt: seenAt }; + const staffId = nonEmptyString4(input.staffId); + if (!staffId) throw new TypeError("staffId is required"); + const timestamp = nonEmptyString4(input.lastSeenAt) ?? nonEmptyString4(input.requestedAt) ?? this.#now(); + const existing = Object.values(this.#state.pendingSenders).find((entry2) => entry2.staffId === staffId); + const entry = { + requestId: existing?.requestId ?? `ding_sender_${this.#idFactory()}`, + staffId, + displayName: displayName(input.displayName ?? input.nick ?? name2), + requestedAt: existing?.requestedAt ?? timestamp, + lastSeenAt: timestamp + }; + this.#state.pendingSenders[entry.requestId] = entry; + await this.#persist(); + return structuredClone(entry); + } + async removePendingSender(requestId) { + const id = nonEmptyString4(requestId); + if (!id || !this.#state.pendingSenders[id]) return false; + delete this.#state.pendingSenders[id]; + await this.#persist(); + return true; + } + async removePendingSenderByStaffId(staffId) { + const id = nonEmptyString4(staffId); + const pending = id ? Object.values(this.#state.pendingSenders).find((entry) => entry.staffId === id) : null; + return pending ? this.removePendingSender(pending.requestId) : false; + } + snapshot() { + return structuredClone(this.#state); + } + async remove() { + await this.#writeQueue; + try { + await unlink2(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)} +`; + const operation = this.#writeQueue.then(async () => { + await mkdir2(dirname2(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.tmp`; + await writeFile2(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename2(temporary, this.#path); + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + } +}; + +// plugin-src/host/channels/dingtalk/connection-supervisor.mjs +var DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); +function retryDelays(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS]; + const valid = value.filter((delay2) => Number.isFinite(delay2) && delay2 >= 0); + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS]; +} +var ConnectionSupervisor = 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 = retryDelays(retryDelaysMs); + this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; + this.#setTimeout = setTimeoutImpl; + this.#clearTimeout = clearTimeoutImpl; + this.#ready = new Promise((resolve4) => { + this.#resolveReady = resolve4; + }); + } + 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 delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-dingtalk] ${connected}/${configured} bots connected; retrying in ${delayMs}ms` + ); + this.#schedule(delayMs); + return; + } + this.#retryIndex = 0; + this.#schedule(this.#healthyIntervalMs); + } catch (error) { + if (this.#closed) return; + const delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-dingtalk] connection reconciliation failed; retrying in ${delayMs}ms`, + error + ); + this.#schedule(delayMs); + } + } +}; +function createConnectionSupervisor(options) { + return new ConnectionSupervisor(options); +} + +// plugin-src/host/channels/dingtalk/production.mjs +function harnessOrigin(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-dingtalk 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-dingtalk")); + return { + root, + config: resolve(config.configPath ?? join(root, "config.json")), + bots: resolve(config.botsDir ?? join(root, "bots")) + }; +} +async function createProductionController(ctx, config = {}, internals = {}) { + if (!ctx?.credentials) throw new TypeError("dsh-dingtalk requires ctx.credentials"); + if (!ctx?.webServer) throw new TypeError("dsh-dingtalk requires ctx.webServer"); + const ConfigStore = internals.ConfigStore ?? DingtalkConfigStore; + const DeviceAuth = internals.DeviceAuth ?? DingtalkDeviceAuth; + const StateStore2 = internals.StateStore ?? DingtalkStateStore; + const Harness = internals.HarnessClient ?? HarnessClient; + const Controller = internals.Controller ?? DingtalkController; + const Runtime = internals.Runtime ?? DingtalkRuntime; + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor; + const logger = typeof ctx.logger === "function" ? ctx.logger("dsh-dingtalk") : ctx.logger ?? console; + const paths = pluginPaths(config); + const configStore = await new ConfigStore(paths.config).load(); + const deviceAuth = internals.deviceAuth ?? new DeviceAuth({ + baseUrl: config.registrationBaseUrl + }); + const stateStores = /* @__PURE__ */ 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 StateStore2(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({ + deviceAuth, + credentials: ctx.credentials, + configStore, + logger, + createRuntime: async ({ botId, config: botConfig, clientSecret }) => { + const state = await stateFor(botId); + return new Runtime({ + config: botConfig, + clientSecret, + harness, + state, + replyTimeoutMs: config.replyTimeoutMs ?? 6e5, + maxMessageChars: config.maxMessageChars ?? 4e3, + connectTimeoutMs: config.connectTimeoutMs ?? 15e3, + 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") { + await state.remove(); + return; + } + try { + await unlink3(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/dingtalk/rpc.mjs +import QRCode from "qrcode"; +var DINGTALK_RPC_CHANNEL = "/dingtalk"; +var DINGTALK_ENDPOINTS = Object.freeze({ + status: "connection.status", + beginProvisioning: "provision.begin", + pollProvisioning: "provision.poll", + cancelProvisioning: "provision.cancel", + reconnectBot: "bot.reconnect", + deleteBot: "bot.delete", + approveSender: "bot.sender.approve", + revokeSender: "bot.sender.revoke" +}); +var DINGTALK_RPC_ENDPOINTS = Object.freeze(Object.values(DINGTALK_ENDPOINTS)); +var FORBIDDEN_PUBLIC_KEYS = /* @__PURE__ */ new Set([ + "clientSecret", + "client_secret", + "deviceCode", + "device_code", + "secretRef", + "staffId", + "senderStaffId", + "verificationUrl", + "verificationUri", + "userCode" +]); +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 === DINGTALK_ENDPOINTS.status) { + return exactKeys(payload, []) ? null : "connection.status does not accept fields."; + } + if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) { + return exactKeys(payload, ["locale"]) && (payload.locale === void 0 || payload.locale === "zh-CN") ? null : "provision.begin received unsupported fields."; + } + if ([DINGTALK_ENDPOINTS.pollProvisioning, DINGTALK_ENDPOINTS.cancelProvisioning].includes(endpoint)) { + return exactKeys(payload, ["attemptId"]) && validId(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; + } + if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) { + return exactKeys(payload, ["botId"]) && validId(payload.botId) ? null : "bot.reconnect requires a botId."; + } + if (endpoint === DINGTALK_ENDPOINTS.deleteBot) { + return exactKeys(payload, ["botId", "confirm"]) && validId(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; + } + if (endpoint === DINGTALK_ENDPOINTS.approveSender) { + return exactKeys(payload, ["botId", "requestId", "confirm"]) && validId(payload.botId) && validId(payload.requestId) && payload.confirm === true ? null : "bot.sender.approve requires botId, requestId, and confirm=true."; + } + if (endpoint === DINGTALK_ENDPOINTS.revokeSender) { + return exactKeys(payload, ["botId", "senderKey", "confirm"]) && validId(payload.botId) && validId(payload.senderKey) && payload.confirm === true ? null : "bot.sender.revoke requires botId, senderKey, and confirm=true."; + } + return "Unknown DingTalk endpoint."; +} +function badRequest(message) { + return { ok: false, error: { code: "bad-request", message } }; +} +function cancelled() { + return { ok: false, error: { code: "cancelled", message: "The request was cancelled." } }; +} +function internalFailure() { + return { + ok: false, + error: { code: "dingtalk-operation-failed", message: "\u9489\u9489\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" } + }; +} +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); +} +function assertController(controller) { + for (const method of [ + "status", + "startProvisioning", + "registrationStatus", + "cancelProvisioning", + "reconnectBot", + "deleteBot", + "approveSender", + "revokeSender" + ]) { + if (typeof controller?.[method] !== "function") { + throw new TypeError(`A complete DingTalk controller is required (${method})`); + } + } +} +function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { + assertController(controller); + 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 cancelled(); + if (!DINGTALK_RPC_ENDPOINTS.includes(endpoint)) return badRequest("Unknown DingTalk endpoint."); + const invalid = payloadFailure(endpoint, payload); + if (invalid) return badRequest(invalid); + try { + let value; + if (endpoint === DINGTALK_ENDPOINTS.status) { + value = await publicStatus(await controller.status(), cachedEncode); + } else if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) { + const started = await controller.startProvisioning({ signal }); + if (signal?.aborted) { + await controller.cancelProvisioning(started.attemptId); + return cancelled(); + } + value = await withEncodedQr(started, cachedEncode); + } else if (endpoint === DINGTALK_ENDPOINTS.pollProvisioning) { + const current = await controller.registrationStatus(payload.attemptId); + if (!current) return badRequest("The provisioning attempt no longer exists."); + value = await withEncodedQr(current, cachedEncode); + } else if (endpoint === DINGTALK_ENDPOINTS.cancelProvisioning) { + value = await controller.cancelProvisioning(payload.attemptId); + if (!value) return badRequest("The provisioning attempt no longer exists."); + value = sanitizePublic(value); + } else if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) { + value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode); + } else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) { + value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode); + } else if (endpoint === DINGTALK_ENDPOINTS.approveSender) { + value = await publicStatus( + await controller.approveSender(payload.botId, payload.requestId), + cachedEncode + ); + } else { + value = await publicStatus( + await controller.revokeSender(payload.botId, payload.senderKey), + cachedEncode + ); + } + return signal?.aborted ? cancelled() : { ok: true, value }; + } catch { + return signal?.aborted ? cancelled() : internalFailure(); + } + }; +} +function installDingtalkRpc(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( + DINGTALK_RPC_CHANNEL, + createDingtalkRpcHandler(controller, options), + { authority: "loopback" } + ); +} + +// plugin-src/host/channels/dingtalk/index.mjs +async function apply(ctx, config = {}) { + if (config?.controller) return installDingtalkRpc(ctx, config.controller, config.rpcOptions); + const production = await createProductionController(ctx, config, config.internals); + const disposeRpc = installDingtalkRpc(ctx, production.controller, config.rpcOptions); + ctx.effect(() => async () => { + await production.close(); + }, "dsh-dingtalk: close bot connections"); + return disposeRpc; +} + +// plugin-src/host/channels/feishu/controller.mjs +var ACTIVE_REGISTRATION_STATES = /* @__PURE__ */ new Set([ + "starting", + "qr_ready", + "polling", + "slow_down", + "domain_switched" +]); +function credentialResult(result) { + const appId = result?.client_id ?? result?.appId; + const appSecret = result?.client_secret ?? result?.appSecret; + if (typeof appId !== "string" || appId.length === 0 || typeof appSecret !== "string" || appSecret.length === 0) { + throw new TypeError("Feishu registration returned invalid credentials"); + } + return { + appId, + appSecret, + userInfo: result?.user_info ?? result?.userInfo + }; +} +async function readConnectionStatus(connectionManager) { + if (typeof connectionManager.status !== "function") return {}; + return await connectionManager.status(); +} +function isConnected(status) { + if (status?.connected === true) return true; + return status?.ready === true && status?.feishuLongConnectionState === "connected" && status?.harnessReachable === true; +} +var ProvisioningBackedController = class { + #credentialStore; + #connectionManager; + #registrationOptions; + #manager; + #knownConfigured = false; + #lastError = null; + constructor({ + createProvisioningManager, + credentialStore, + connectionManager, + registrationOptions = {} + } = {}) { + if (typeof createProvisioningManager !== "function") { + throw new TypeError("createProvisioningManager is required"); + } + if (!credentialStore || typeof credentialStore.save !== "function" || typeof credentialStore.clear !== "function") { + throw new TypeError("credentialStore.save/clear are required"); + } + if (!connectionManager || typeof connectionManager.connect !== "function" || typeof connectionManager.disconnect !== "function") { + throw new TypeError("connectionManager.connect/disconnect are required"); + } + if (registrationOptions === null || typeof registrationOptions !== "object" || Array.isArray(registrationOptions)) { + throw new TypeError("registrationOptions must be an object"); + } + this.#credentialStore = credentialStore; + this.#connectionManager = connectionManager; + this.#registrationOptions = structuredClone(registrationOptions); + this.#manager = createProvisioningManager({ + onCredentials: (result) => this.#acceptCredentials(result) + }); + if (!this.#manager || typeof this.#manager.start !== "function" || typeof this.#manager.status !== "function" || typeof this.#manager.cancel !== "function") { + throw new TypeError("The provisioning manager must implement start/status/cancel"); + } + } + async startRegistration() { + this.#lastError = null; + await this.#manager.start(structuredClone(this.#registrationOptions)); + return this.status(); + } + async cancelRegistration() { + await this.#manager.cancel(); + return this.status(); + } + async disconnect() { + await this.#manager.cancel(); + await this.#connectionManager.disconnect(); + try { + await this.#credentialStore.clear(); + this.#knownConfigured = false; + this.#lastError = null; + } catch { + this.#lastError = { + code: "credential_removal_failed", + message: "Unable to remove the Feishu credentials." + }; + } + return this.status(); + } + async status() { + const registration = await this.#manager.status(); + const connection = await readConnectionStatus(this.#connectionManager); + const connected = isConnected(connection); + let configured = this.#knownConfigured; + if (typeof this.#credentialStore.configured === "function") { + try { + configured = await this.#credentialStore.configured(); + } catch { + configured = this.#knownConfigured; + } + } + let phase = "unconfigured"; + if (connected) phase = "connected"; + else if (ACTIVE_REGISTRATION_STATES.has(registration?.state)) phase = "registering"; + else if (registration?.state === "saving") phase = "connecting"; + else if (this.#lastError || registration?.state === "error") phase = "error"; + else if (configured) phase = "disconnected"; + return { + phase, + connected, + configured, + registration, + connection, + error: this.#lastError ?? registration?.error ?? null + }; + } + async close() { + await this.#manager.cancel(); + await this.#connectionManager.disconnect(); + } + async #acceptCredentials(result) { + const credentials = credentialResult(result); + try { + await this.#credentialStore.save(credentials); + this.#knownConfigured = true; + await this.#connectionManager.connect(credentials); + this.#lastError = null; + } catch { + this.#lastError = { + code: "connection_failed", + message: "The bot was created, but its connection could not be started." + }; + throw new Error("Unable to activate the Feishu connection."); + } + } +}; +function createProvisioningBackedController(options) { + return new ProvisioningBackedController(options); +} + +// plugin-src/host/channels/feishu/production.mjs +import { homedir as homedir2 } from "node:os"; +import { join as join2, resolve as resolve2 } from "node:path"; +import { unlink as unlink5 } from "node:fs/promises"; +import * as Lark from "@larksuiteoapi/node-sdk"; + +// plugin-src/host/channels/feishu/connection-supervisor.mjs +var DEFAULT_RETRY_DELAYS_MS2 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); +function safeDelay(value, fallback) { + return Number.isFinite(value) && value >= 0 ? value : fallback; +} +function safeRetryDelays(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS2]; + const delays = value.map((delay2) => safeDelay(delay2, -1)).filter((delay2) => delay2 >= 0); + return delays.length > 0 ? delays : [...DEFAULT_RETRY_DELAYS_MS2]; +} +function totals(status) { + const configured = Number.isInteger(status?.totals?.configured) ? status.totals.configured : Array.isArray(status?.bots) ? status.bots.length : 0; + const connected = Number.isInteger(status?.totals?.connected) ? status.totals.connected : Array.isArray(status?.bots) ? status.bots.filter((bot) => bot?.connected === true).length : 0; + return { configured, connected }; +} +var ConnectionSupervisor2 = class { + #controller; + #harness; + #logger; + #retryDelaysMs; + #healthyIntervalMs; + #setTimeout; + #clearTimeout; + #timer = null; + #running = null; + #retryIndex = 0; + #started = false; + #closed = 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.#retryDelaysMs = safeRetryDelays(retryDelaysMs); + this.#healthyIntervalMs = safeDelay(healthyIntervalMs, 15e3); + this.#setTimeout = setTimeoutImpl; + this.#clearTimeout = clearTimeoutImpl; + this.#ready = new Promise((resolve4) => { + this.#resolveReady = resolve4; + }); + } + 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(); + } catch (error) { + if (this.#closed) return; + this.#retry("Harness Host is not ready", error); + return; + } + if (this.#closed) return; + try { + await this.#controller.initialize(); + if (this.#closed) return; + const status = this.#controller.status(); + this.#resolveReady?.(status); + this.#resolveReady = null; + const current = totals(status); + if (current.connected < current.configured) { + const delay2 = this.#retryDelaysMs[Math.min(this.#retryIndex, this.#retryDelaysMs.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-feishu] ${current.connected}/${current.configured} bots connected; retrying automatically in ${delay2}ms` + ); + this.#schedule(delay2); + return; + } + this.#retryIndex = 0; + this.#schedule(this.#healthyIntervalMs); + } catch (error) { + if (this.#closed) return; + this.#retry("Bot connection reconciliation failed", error); + } + } + #retry(message, error) { + const delay2 = this.#retryDelaysMs[Math.min(this.#retryIndex, this.#retryDelaysMs.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-feishu] ${message}; retrying automatically in ${delay2}ms`, + error + ); + this.#schedule(delay2); + } +}; +function createConnectionSupervisor2(options) { + return new ConnectionSupervisor2(options); +} + +// src/channels/feishu/feishu-app.mjs +function endpointFor(domain, path) { + const origin = domain === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn"; + return new URL(path, origin); +} +async function jsonResponse(response, operation) { + let body; + try { + body = await response.json(); + } catch { + throw new Error(`${operation} returned a non-JSON response`); + } + if (!response.ok || body?.code !== 0) { + throw new Error(`${operation} failed: ${body?.msg || `HTTP ${response.status}`}`); + } + return body; +} +async function verifyFeishuApp({ + appId, + appSecret, + domain = "feishu", + fetchImpl = fetch, + timeoutMs = 15e3 +}) { + if (!appId || !appSecret) throw new Error("Feishu credentials are incomplete"); + const tokenResponse = await fetchImpl(endpointFor(domain, "/open-apis/auth/v3/tenant_access_token/internal"), { + method: "POST", + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ app_id: appId, app_secret: appSecret }), + signal: AbortSignal.timeout(timeoutMs) + }); + const tokenBody = await jsonResponse(tokenResponse, "Feishu authentication"); + if (!tokenBody.tenant_access_token) { + throw new Error("Feishu authentication returned no tenant access token"); + } + const botResponse = await fetchImpl(endpointFor(domain, "/open-apis/bot/v3/info/"), { + headers: { authorization: `Bearer ${tokenBody.tenant_access_token}` }, + signal: AbortSignal.timeout(timeoutMs) + }); + const botBody = await jsonResponse(botResponse, "Feishu bot verification"); + const bot = botBody.bot ?? {}; + return Object.freeze({ + appId, + name: bot.app_name ?? bot.bot_name ?? null, + openId: bot.open_id ?? null, + activated: bot.activate_status ?? null + }); +} + +// src/channels/feishu/message-utils.mjs +function conversationKey2(event) { + const chatType = event?.message?.chat_type; + if (chatType === "p2p") { + const senderId = event?.sender?.sender_id?.open_id || event?.sender?.sender_id?.user_id; + if (!senderId) throw new Error("Feishu p2p event has no sender id"); + return `p2p:${senderId}`; + } + const chatId = event?.message?.chat_id; + if (!chatId) throw new Error("Feishu group event has no chat id"); + return `group:${chatId}`; +} +function extractText(event) { + if (event?.message?.message_type !== "text") return null; + let parsed; + try { + parsed = JSON.parse(event.message.content); + } catch { + return null; + } + let text = typeof parsed.text === "string" ? parsed.text : ""; + for (const mention of event.message.mentions ?? []) { + if (typeof mention.key === "string" && mention.key) text = text.replaceAll(mention.key, ""); + } + return text.trim(); +} +function splitText(text, maxChars = 9e3) { + if (text.length <= maxChars) return [text]; + const chunks = []; + let remaining = text; + while (remaining.length > maxChars) { + let splitAt = remaining.lastIndexOf("\n", maxChars); + if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n+/, ""); + } + if (remaining) chunks.push(remaining); + return chunks; +} +function isBotSender(event) { + return event?.sender?.sender_type === "bot"; +} +function isAllowedSender(event, allowedOpenIds) { + if (!allowedOpenIds || allowedOpenIds.size === 0) return false; + const senderOpenId = event?.sender?.sender_id?.open_id; + return typeof senderOpenId === "string" && allowedOpenIds.has(senderOpenId); +} + +// src/channels/feishu/bridge.mjs +var HELP_TEXT2 = [ + "\u5317\u6C47\u661F\u6CB3 AIOS \u5DF2\u8FDE\u63A5 DeepSeek Harness\u3002", + "", + "\u76F4\u63A5\u53D1\u9001\u95EE\u9898\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 FeishuHarnessBridge = class { + #client; + #channel; + #harness; + #state; + #queues = /* @__PURE__ */ new Map(); + #acceptedMessageIds = /* @__PURE__ */ new Set(); + #status; + #allowedSenderOpenIds; + #replyTimeoutMs; + constructor({ + client, + channel, + harness, + state, + status, + allowedSenderOpenIds = /* @__PURE__ */ new Set(), + replyTimeoutMs = 6e5 + }) { + this.#client = client; + this.#channel = channel; + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#allowedSenderOpenIds = allowedSenderOpenIds; + this.#replyTimeoutMs = replyTimeoutMs; + } + accept(event) { + const messageId = event?.message?.message_id; + if (!messageId || isBotSender(event) || event?.message?.message_type !== "text") return; + if (!isAllowedSender(event, this.#allowedSenderOpenIds)) { + this.#status.messagesRejected += 1; + this.#status.lastRejectedAt = (/* @__PURE__ */ new Date()).toISOString(); + console.warn("[bridge] ignored a message from a sender outside the allowlist"); + return; + } + if (this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) return; + this.#acceptedMessageIds.add(messageId); + const processingReaction = this.#addReaction(messageId, "OnIt"); + const key = conversationKey2(event); + const previous = this.#queues.get(key) ?? Promise.resolve(); + const task = previous.catch(() => void 0).then(() => this.#handle(event, key)).then(() => this.#finishReaction(messageId, processingReaction, "DONE")).catch(async (error) => { + console.error("[bridge] message handling failed:", error.message); + this.#status.lastError = error.message; + await this.#finishReaction(messageId, processingReaction, "ERROR"); + await this.#send( + event.message.chat_id, + "\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002\u5982\u679C\u95EE\u9898\u6301\u7EED\uFF0C\u8BF7\u5728 DeepSeek Harness \u7684\u98DE\u4E66\u63D2\u4EF6\u9875\u9762\u68C0\u67E5\u8FDE\u63A5\u72B6\u6001\u3002" + ).catch(() => void 0); + }).finally(() => { + this.#acceptedMessageIds.delete(messageId); + if (this.#queues.get(key) === task) this.#queues.delete(key); + }); + this.#queues.set(key, task); + } + async waitForIdle() { + await Promise.allSettled([...this.#queues.values()]); + } + async #handle(event, key) { + const messageId = event.message.message_id; + await this.#state.markSeen(messageId); + this.#status.lastMessageAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.messagesReceived += 1; + const text = extractText(event); + if (!text) return; + if (text === "/help") { + await this.#send(event.message.chat_id, HELP_TEXT2); + return; + } + if (text === "/new") { + await this.#state.clearSession(key); + await this.#send(event.message.chat_id, "\u5DF2\u5F00\u542F\u5168\u65B0 Harness \u4F1A\u8BDD\u3002"); + return; + } + if (text === "/status") { + await this.#harness.ensureRunning(); + await this.#send(event.message.chat_id, "\u98DE\u4E66\u673A\u5668\u4EBA\u4E0E DeepSeek Harness \u8FDE\u63A5\u6B63\u5E38\u3002"); + 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); + } + console.info(`[bridge] processing ${event.message.chat_type} message ${messageId} in ${sessionId}`); + await this.#answerWithStream(event, sessionId, text); + this.#status.messagesReplied += 1; + this.#status.lastReplyAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.lastError = null; + } + async #answerWithStream(event, sessionId, text) { + const chatId = event.message.chat_id; + const messageId = event.message.message_id; + if (!this.#channel?.stream) { + const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs }); + for (const chunk of splitText(answer)) await this.#send(chatId, chunk); + this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1; + return; + } + let promptStarted = false; + let completedAnswer = ""; + try { + await this.#channel.stream(chatId, { + markdown: async (controller) => { + promptStarted = true; + completedAnswer = await this.#harness.ask(sessionId, text, { + timeoutMs: this.#replyTimeoutMs, + onUpdate: async (update) => { + await controller.setContent(this.#progressText(update)); + this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1; + } + }); + await controller.setContent(completedAnswer); + } + }, { replyTo: messageId }); + this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1; + } catch (error) { + this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1; + if (completedAnswer) { + console.warn("[bridge] native Feishu stream failed after generation; sending final text:", error.message); + for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk); + this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1; + return; + } + if (promptStarted) throw error; + console.warn("[bridge] native Feishu stream unavailable; using text fallback:", error.message); + const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs }); + for (const chunk of splitText(answer)) await this.#send(chatId, chunk); + this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1; + } + } + #progressText(update) { + if (update.type === "text" && update.text) return update.text; + if (update.type === "tool") { + if (update.name === "web_search") return "_\u6B63\u5728\u641C\u7D22\u7F51\u7EDC\u5E76\u6574\u7406\u4FE1\u606F\u2026_"; + return `_\u6B63\u5728\u4F7F\u7528 ${update.name || "\u5DE5\u5177"}\u2026_`; + } + return `_${update.text || "\u6B63\u5728\u5904\u7406\u2026"}_`; + } + async #addReaction(messageId, emojiType) { + if (!this.#channel?.addReaction) return null; + try { + const reactionId = await this.#channel.addReaction(messageId, emojiType); + this.#status.reactionsAdded = (this.#status.reactionsAdded ?? 0) + 1; + return reactionId; + } catch (error) { + this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1; + console.warn(`[bridge] unable to add ${emojiType} reaction:`, error.message); + return null; + } + } + async #finishReaction(messageId, processingReaction, finalEmojiType) { + const reactionId = await processingReaction; + if (reactionId && this.#channel?.removeReaction) { + try { + await this.#channel.removeReaction(messageId, reactionId); + this.#status.reactionsRemoved = (this.#status.reactionsRemoved ?? 0) + 1; + } catch (error) { + this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1; + console.warn("[bridge] unable to remove processing reaction:", error.message); + } + } + await this.#addReaction(messageId, finalEmojiType); + } + async #send(chatId, text) { + const response = await this.#client.im.v1.message.create({ + params: { receive_id_type: "chat_id" }, + data: { + receive_id: chatId, + msg_type: "text", + content: JSON.stringify({ text }) + } + }); + if (response?.code && response.code !== 0) { + throw new Error(`Feishu send failed: ${response.msg || response.code}`); + } + } +}; + +// src/channels/feishu/feishu-channel.mjs +var STREAM_ELEMENT_ID = "stream_md"; +var DEFAULT_INITIAL_TEXT = "\u5DF2\u8FDE\u63A5 DeepSeek Harness\uFF0C\u6B63\u5728\u601D\u8003\u2026"; +var MAX_STREAM_CHARS = 28e3; +function assertApiSuccess(operation, response) { + if (response?.code && response.code !== 0) { + throw new Error(`${operation} failed: ${response.msg || response.code}`); + } + return response; +} +function summaryOf(text) { + const summary = String(text ?? "").replace(/\s+/g, " ").trim(); + return summary.length <= 50 ? summary : `${summary.slice(0, 49)}\u2026`; +} +function streamingCard(initialText) { + return { + schema: "2.0", + config: { + streaming_mode: true, + summary: { content: "\u6B63\u5728\u751F\u6210\u2026" }, + streaming_config: { + print_frequency_ms: { default: 70 }, + print_step: { default: 1 }, + print_strategy: "fast" + } + }, + body: { + elements: [{ + tag: "markdown", + element_id: STREAM_ELEMENT_ID, + content: initialText + }] + } + }; +} +var VerifiedFeishuChannel = class { + #client; + #initialText; + constructor({ client, initialText = DEFAULT_INITIAL_TEXT }) { + this.#client = client; + this.#initialText = initialText; + } + async stream(chatId, input, options = {}) { + if (typeof input?.markdown !== "function") { + throw new Error("Feishu stream requires a markdown producer"); + } + let messageId = null; + const cardResponse = assertApiSuccess("Feishu card.create", await this.#client.cardkit.v1.card.create({ + data: { + type: "card_json", + data: JSON.stringify(streamingCard(this.#initialText)) + } + })); + const cardId = cardResponse?.data?.card_id; + if (!cardId) throw new Error("Feishu card.create returned no card_id"); + try { + messageId = await this.#sendCard(chatId, cardId, options.replyTo); + let sequence = 0; + let lastContent = this.#initialText; + const controller = { + messageId, + setContent: async (content) => { + const next = String(content ?? "") || "\u2026"; + if (next === lastContent) return; + if (next.length > MAX_STREAM_CHARS) { + throw new Error(`Feishu stream content exceeds ${MAX_STREAM_CHARS} characters`); + } + const response = await this.#client.cardkit.v1.cardElement.content({ + path: { card_id: cardId, element_id: STREAM_ELEMENT_ID }, + data: { + content: next, + sequence: ++sequence, + uuid: `content_${cardId}_${sequence}` + } + }); + assertApiSuccess("Feishu cardElement.content", response); + lastContent = next; + } + }; + await input.markdown(controller); + const finishResponse = await this.#client.cardkit.v1.card.settings({ + path: { card_id: cardId }, + data: { + settings: JSON.stringify({ + config: { + streaming_mode: false, + summary: { content: summaryOf(lastContent) || "\u56DE\u7B54\u5B8C\u6210" } + } + }), + sequence: ++sequence, + uuid: `settings_${cardId}_${sequence}` + } + }); + assertApiSuccess("Feishu card.settings", finishResponse); + return { messageId }; + } catch (error) { + if (messageId) await this.#recall(messageId); + throw error; + } + } + async #sendCard(chatId, cardId, replyTo) { + const content = JSON.stringify({ type: "card", data: { card_id: cardId } }); + const response = replyTo ? await this.#client.im.v1.message.reply({ + path: { message_id: replyTo }, + data: { msg_type: "interactive", content } + }) : await this.#client.im.v1.message.create({ + params: { receive_id_type: "chat_id" }, + data: { receive_id: chatId, msg_type: "interactive", content } + }); + assertApiSuccess("Feishu message send", response); + const messageId = response?.data?.message_id; + if (!messageId) throw new Error("Feishu message send returned no message_id"); + return messageId; + } + async #recall(messageId) { + try { + const response = await this.#client.im.v1.message.delete({ + path: { message_id: messageId } + }); + assertApiSuccess("Feishu message delete", response); + } catch (error) { + console.warn("[bridge] unable to recall a failed streaming card:", error.message); + } + } + async addReaction(messageId, emojiType) { + const response = assertApiSuccess("Feishu reaction.create", await this.#client.im.v1.messageReaction.create({ + path: { message_id: messageId }, + data: { reaction_type: { emoji_type: emojiType } } + })); + const reactionId = response?.data?.reaction_id; + if (!reactionId) throw new Error("Feishu reaction.create returned no reaction_id"); + return reactionId; + } + async removeReaction(messageId, reactionId) { + assertApiSuccess("Feishu reaction.delete", await this.#client.im.v1.messageReaction.delete({ + path: { message_id: messageId, reaction_id: reactionId } + })); + } +}; + +// src/channels/feishu/feishu-runtime.mjs +function createBridgeStatus({ allowedSenderCount = 1 } = {}) { + return { + startedAt: null, + ready: false, + feishuLongConnectionState: "idle", + harnessReachable: false, + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + reactionsAdded: 0, + reactionsRemoved: 0, + reactionErrors: 0, + streamResponses: 0, + streamUpdates: 0, + streamFallbacks: 0, + streamErrors: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null, + agentPreset: "standard", + authorizationMode: "sender-open-id-allowlist", + allowedSenderCount + }; +} +var FeishuRuntime = class { + #lark; + #appId; + #appSecret; + #domain; + #ownerOpenIds; + #harness; + #state; + #replyTimeoutMs; + #connectTimeoutMs; + #logger; + #client = null; + #bridge = null; + #wsClient = null; + #starting = null; + #status; + constructor({ + lark, + appId, + appSecret, + domain = "feishu", + ownerOpenId, + ownerOpenIds, + harness, + state, + replyTimeoutMs = 6e5, + connectTimeoutMs = 15e3, + logger = console + }) { + if (!lark) throw new Error("FeishuRuntime requires the Feishu SDK"); + if (!appId || !appSecret) throw new Error("FeishuRuntime requires app credentials"); + const allowedOwners = Array.isArray(ownerOpenIds) ? ownerOpenIds : [ownerOpenId]; + const normalizedOwners = [...new Set(allowedOwners.filter((value) => typeof value === "string" && value))]; + if (normalizedOwners.length === 0) throw new Error("FeishuRuntime requires at least one owner open_id"); + if (!harness) throw new Error("FeishuRuntime requires a Harness client"); + if (!state) throw new Error("FeishuRuntime requires a state store"); + this.#lark = lark; + this.#appId = appId; + this.#appSecret = appSecret; + this.#domain = domain; + this.#ownerOpenIds = normalizedOwners; + this.#harness = harness; + this.#state = state; + this.#replyTimeoutMs = replyTimeoutMs; + this.#connectTimeoutMs = connectTimeoutMs; + this.#logger = logger; + this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length }); + } + get status() { + return structuredClone(this.#status); + } + async start() { + if (this.#wsClient && this.#status.ready) return this.status; + if (this.#starting) return this.#starting; + this.#starting = this.#start().finally(() => { + this.#starting = null; + }); + return this.#starting; + } + async #start() { + this.#status.startedAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.feishuLongConnectionState = "connecting"; + this.#status.lastError = null; + try { + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + const sdkDomain = this.#domain === "lark" ? this.#lark.Domain.Lark : this.#lark.Domain.Feishu; + const larkConfig = { + appId: this.#appId, + appSecret: this.#appSecret, + domain: sdkDomain + }; + this.#client = new this.#lark.Client(larkConfig); + const channel = new VerifiedFeishuChannel({ + client: this.#client, + initialText: "\u5DF2\u8FDE\u63A5 DeepSeek Harness\uFF0C\u6B63\u5728\u601D\u8003\u2026" + }); + this.#bridge = new FeishuHarnessBridge({ + client: this.#client, + channel, + harness: this.#harness, + state: this.#state, + status: this.#status, + allowedSenderOpenIds: new Set(this.#ownerOpenIds), + replyTimeoutMs: this.#replyTimeoutMs + }); + const dispatcher = new this.#lark.EventDispatcher({}).register({ + "im.message.receive_v1": (event) => { + this.#bridge.accept(event); + return {}; + }, + "im.message.reaction.created_v1": () => ({}), + "im.message.reaction.deleted_v1": () => ({}) + }); + let settleReady; + let settleError; + const ready = new Promise((resolve4, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error(`Feishu WebSocket handshake timed out after ${this.#connectTimeoutMs}ms`)); + }, this.#connectTimeoutMs); + settleReady = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve4(); + }; + settleError = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }; + }); + this.#wsClient = new this.#lark.WSClient({ + ...larkConfig, + loggerLevel: this.#lark.LoggerLevel.info, + handshakeTimeoutMs: 15e3, + onReady: () => { + this.#status.feishuLongConnectionState = "connected"; + this.#status.ready = true; + this.#status.lastError = null; + settleReady(); + }, + onError: (error) => { + this.#status.feishuLongConnectionState = "failed"; + this.#status.ready = false; + this.#status.lastError = error?.message ?? String(error); + this.#logger.error("[dsh-feishu] Feishu long connection failed:", this.#status.lastError); + settleError(error); + }, + onReconnecting: () => { + this.#status.feishuLongConnectionState = "reconnecting"; + this.#status.ready = false; + }, + onReconnected: () => { + this.#status.feishuLongConnectionState = "connected"; + this.#status.ready = true; + this.#status.lastError = null; + } + }); + await this.#wsClient.start({ eventDispatcher: dispatcher }).catch((error) => { + settleError(error); + }); + await ready; + return this.status; + } catch (error) { + this.#status.ready = false; + this.#status.feishuLongConnectionState = "failed"; + this.#status.lastError = error?.message ?? String(error); + await this.stop({ preserveError: true }); + throw error; + } + } + async stop({ preserveError = false } = {}) { + const error = preserveError ? this.#status.lastError : null; + this.#status.ready = false; + if (this.#wsClient) { + this.#wsClient.close({ force: true }); + this.#wsClient = null; + } + if (this.#bridge) { + await this.#bridge.waitForIdle(); + this.#bridge = null; + } + this.#client = null; + this.#status.feishuLongConnectionState = preserveError ? "failed" : "idle"; + this.#status.lastError = error; + return this.status; + } +}; + +// 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((resolve4) => setTimeout(resolve4, 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(); +} +var HarnessReplyTracker2 = class { + #promptRpcId; + #lastSeq; + #openTurn = null; + #targetTurn = null; + #stepText = /* @__PURE__ */ new Map(); + #latestText = ""; + #finished = false; + #reason = null; + constructor({ promptRpcId, afterSeq = -1 }) { + this.#promptRpcId = promptRpcId; + this.#lastSeq = afterSeq; + } + get finished() { + return this.#finished; + } + get answer() { + return this.#latestText.trim(); + } + get reason() { + return this.#reason; + } + consume(entries) { + let update = null; + const ordered = [...entries].map((entry) => entry?.event ?? entry).filter(Boolean).sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1)); + for (const event of ordered) { + const seq = event.seq ?? -1; + if (seq <= this.#lastSeq) continue; + this.#lastSeq = seq; + if (event.type === "turn/start") { + this.#openTurn = event.data?.turn ?? null; + } + if (event.type === "user/message" && event.data?.source?.rpcId === this.#promptRpcId) { + this.#targetTurn = this.#openTurn; + continue; + } + if (this.#targetTurn === null) continue; + if (event.type === "turn/end") { + if (event.data?.turn !== this.#targetTurn) continue; + this.#finished = true; + this.#reason = event.data?.reason ?? null; + this.#openTurn = null; + continue; + } + if (event.data?.turn !== this.#targetTurn) continue; + if (event.type === "assistant/chunk" && event.data?.chunk?.type === "text-delta") { + const step = event.data?.step ?? 0; + const index = event.data.chunk.index ?? 0; + const key = `${step}:${index}`; + this.#stepText.set(key, (this.#stepText.get(key) ?? "") + event.data.chunk.text); + const stepPrefix = `${step}:`; + const text = [...this.#stepText.entries()].filter(([partKey]) => partKey.startsWith(stepPrefix)).sort(([left], [right]) => Number(left.split(":")[1]) - Number(right.split(":")[1])).map(([, part]) => part).join("\n").trim(); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: "text", text }; + } + continue; + } + if (event.type === "assistant/message") { + const text = messageText(event); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: "text", text }; + } + continue; + } + if (event.type === "tool/call") { + update = { type: "tool", name: event.data?.name ?? "\u5DE5\u5177" }; + } else if (event.type === "tool/result") { + update = { type: "status", text: "\u6B63\u5728\u6574\u7406\u7ED3\u679C\u2026" }; + } + } + return update; + } +}; +var HarnessRpcError2 = class extends Error { + constructor(method, error) { + super(`${method}: ${error?.message ?? "unknown Harness RPC error"}`); + this.name = "HarnessRpcError"; + this.method = method; + this.code = error?.code ?? "internal"; + this.details = error?.details ?? {}; + } +}; +var HarnessClient2 = class { + #baseUrl; + #workspace; + #agentPreset; + #autostart; + #dshBin; + #managedProcess = null; + constructor({ baseUrl, workspace, agentPreset, autostart, dshBin }) { + this.#baseUrl = new URL(baseUrl); + this.#workspace = workspace; + this.#agentPreset = agentPreset; + this.#autostart = autostart; + this.#dshBin = dshBin; + } + async rpc(method, payload = {}, timeoutMs = 3e4, options = {}) { + const rpcId = options.rpcId ?? `feishu-${randomUUID6()}`; + const response = await fetch(new URL(`/api/${method}`, this.#baseUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "client-request", rpcId, method, payload }), + signal: AbortSignal.timeout(timeoutMs) + }); + if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`); + const body = await response.json(); + if (body?.type !== "server-response" || body?.rpcId !== rpcId) { + throw new Error(`Harness returned an invalid response for ${method}`); + } + if (!body.result?.ok) throw new HarnessRpcError2(method, body.result?.error); + return body.result.value; + } + async health() { + await this.rpc("host.describe", {}, 5e3); + return true; + } + async ensureRunning() { + try { + return await this.health(); + } catch (firstError) { + if (!this.#autostart) throw firstError; + } + if (!this.#managedProcess || this.#managedProcess.exitCode !== null) { + const port = this.#baseUrl.port || (this.#baseUrl.protocol === "https:" ? "443" : "80"); + this.#managedProcess = spawn2(this.#dshBin, [ + "web", + "--host", + this.#baseUrl.hostname, + "--port", + port + ], { + cwd: this.#workspace, + env: process.env, + stdio: ["ignore", "inherit", "inherit"] + }); + this.#managedProcess.on("error", (error) => { + console.error("[bridge] failed to start Harness:", error.message); + }); + } + const deadline = Date.now() + 6e4; + let lastError; + while (Date.now() < deadline) { + await sleep2(1e3); + try { + return await this.health(); + } catch (error) { + lastError = error; + } + } + throw new Error(`Harness did not become ready: ${lastError?.message ?? "timeout"}`); + } + async workspaceId() { + const { items } = await this.rpc("workspace.list", {}); + const existing = items.find((item) => item.path === this.#workspace); + if (existing) return existing.workspaceId; + const created = await this.rpc("workspace.create", { path: this.#workspace }); + return created.workspace.workspaceId; + } + async createSession() { + await this.ensureRunning(); + const workspaceId = await this.workspaceId(); + const created = await this.rpc("session.create", { + workspaceId, + agentPreset: this.#agentPreset + }); + return created.sessionId; + } + async sessionExists(sessionId) { + try { + await this.rpc("session.history", { sessionId, maxMessages: 1 }); + return true; + } catch (error) { + if (error instanceof HarnessRpcError2 && error.code === "session-not-found") return false; + throw error; + } + } + async ask(sessionId, text, options = {}) { + if (typeof options === "number") options = { timeoutMs: options }; + const timeoutMs = options.timeoutMs ?? 6e5; + const onUpdate = typeof options.onUpdate === "function" ? options.onUpdate : null; + await this.ensureRunning(); + const before = await this.rpc("session.history", { sessionId, maxMessages: 1 }); + const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1)); + const promptRpcId = `feishu-${randomUUID6()}`; + const tracker = new HarnessReplyTracker2({ promptRpcId, afterSeq: baselineSeq }); + await this.rpc("session.prompt", { + sessionId, + mode: "queue", + content: [{ type: "text", text }], + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone + }, 3e4, { rpcId: promptRpcId }); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep2(300); + const history = await this.rpc("session.history", { sessionId, maxMessages: 50 }); + const update = tracker.consume(history.events ?? []); + if (update && onUpdate) { + try { + await onUpdate(update); + } catch (error) { + console.warn("[bridge] ignored a progress update failure:", error.message); + } + } + if (!tracker.finished) continue; + if (tracker.answer) return tracker.answer; + throw new Error(`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ""}`); + } + throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1e3)} seconds`); + } + stopManagedProcess() { + if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill("SIGTERM"); + } +}; + +// src/channels/feishu/plugin-config-store.mjs +import { createHash as createHash2 } from "node:crypto"; +import { mkdir as mkdir3, readFile as readFile3, rename as rename3, unlink as unlink4, writeFile as writeFile3 } from "node:fs/promises"; +import { dirname as dirname3 } from "node:path"; +var LEGACY_FEISHU_SECRET_REF = "DSH_FEISHU_APP_SECRET"; +function cleanString4(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function safeId(value) { + const id = cleanString4(value); + return id && /^[A-Za-z0-9_-]{1,128}$/.test(id) ? id : null; +} +function legacyBotId(appId) { + return `bot_${createHash2("sha256").update(appId).digest("hex").slice(0, 24)}`; +} +function normalizeOwners(value) { + const candidates = Array.isArray(value.ownerOpenIds) ? value.ownerOpenIds : [value.ownerOpenId]; + return [...new Set(candidates.map(cleanString4).filter(Boolean))]; +} +function normalizeBot2(value, { legacy = false } = {}) { + if (!value || typeof value !== "object") return null; + const appId = cleanString4(value.appId); + const ownerOpenIds = normalizeOwners(value); + if (!appId || ownerOpenIds.length === 0) return null; + const id = safeId(value.id) ?? (legacy ? legacyBotId(appId) : null); + const secretRef = cleanString4(value.secretRef) ?? (legacy ? LEGACY_FEISHU_SECRET_REF : null); + if (!id || !secretRef) return null; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretRef)) return null; + const domain = value.domain === "lark" ? "lark" : "feishu"; + return Object.freeze({ + id, + appId, + secretRef, + ownerOpenIds: Object.freeze(ownerOpenIds), + domain, + botName: cleanString4(value.botName), + botOpenId: cleanString4(value.botOpenId), + activated: value.activated ?? null, + deletionPending: value.deletionPending === true, + connectedAt: cleanString4(value.connectedAt), + createdAt: cleanString4(value.createdAt) ?? cleanString4(value.connectedAt) + }); +} +function normalizeDocument2(value) { + if (!value || typeof value !== "object") return null; + if (value.version === 2 && Array.isArray(value.bots)) { + const bots = value.bots.map((bot) => normalizeBot2(bot)); + if (bots.some((bot) => bot === null)) { + throw new Error("dsh-feishu config contains an invalid bot entry"); + } + const ids = /* @__PURE__ */ new Set(); + const refs = /* @__PURE__ */ new Set(); + const appIds = /* @__PURE__ */ new Set(); + for (const bot of bots) { + if (ids.has(bot.id) || refs.has(bot.secretRef) || appIds.has(bot.appId)) { + throw new Error("dsh-feishu config contains duplicate bot identities"); + } + ids.add(bot.id); + refs.add(bot.secretRef); + appIds.add(bot.appId); + } + return { value: Object.freeze({ version: 2, bots: Object.freeze(bots) }), migrated: false }; + } + const legacyBot = normalizeBot2(value, { legacy: true }); + if (!legacyBot) return null; + return { + value: Object.freeze({ version: 2, bots: Object.freeze([legacyBot]) }), + migrated: true + }; +} +var PluginConfigStore = class { + #path; + #value = Object.freeze({ version: 2, bots: Object.freeze([]) }); + #writeQueue = Promise.resolve(); + constructor(path) { + this.#path = path; + } + async load() { + try { + const parsed = JSON.parse(await readFile3(this.#path, "utf8")); + const normalized = normalizeDocument2(parsed); + if (!normalized) throw new Error("dsh-feishu config is incomplete or invalid"); + this.#value = normalized.value; + if (normalized.migrated) await this.#writeDocument(this.#value); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + this.#value = Object.freeze({ version: 2, bots: Object.freeze([]) }); + } + return this; + } + /** Backward-compatible single-bot view used by the original controller. */ + get() { + const bot = this.#value.bots[0]; + if (!bot) return null; + const result = structuredClone(bot); + result.ownerOpenId = result.ownerOpenIds[0]; + return result; + } + list() { + return structuredClone(this.#value.bots); + } + getBot(id) { + const bot = this.#value.bots.find((candidate) => candidate.id === id); + return bot ? structuredClone(bot) : null; + } + /** Backward-compatible save replaces the original single-bot view. */ + async save(value) { + const fallback = { ...value }; + if (!fallback.id) fallback.id = legacyBotId(cleanString4(fallback.appId) ?? "invalid"); + if (!fallback.secretRef) fallback.secretRef = LEGACY_FEISHU_SECRET_REF; + const normalized = normalizeBot2(fallback); + if (!normalized) throw new Error("Refusing to persist incomplete dsh-feishu configuration"); + await this.#replaceBots([normalized]); + return this.get(); + } + async saveBot(value) { + const normalized = normalizeBot2(value); + if (!normalized) throw new Error("Refusing to persist incomplete dsh-feishu bot configuration"); + return this.#mutate((bots) => { + const collision = bots.find((bot) => bot.secretRef === normalized.secretRef && bot.id !== normalized.id); + if (collision) throw new Error("Refusing to share a credential reference between Feishu bots"); + const appCollision = bots.find((bot) => bot.appId === normalized.appId && bot.id !== normalized.id); + if (appCollision) throw new Error("Refusing to persist the same Feishu app twice"); + const index = bots.findIndex((bot) => bot.id === normalized.id); + if (index === -1) bots.push(normalized); + else bots[index] = normalized; + return structuredClone(normalized); + }); + } + async removeBot(id) { + if (!safeId(id)) throw new TypeError("Invalid Feishu bot id"); + return this.#mutate((bots) => { + const index = bots.findIndex((bot) => bot.id === id); + if (index === -1) return null; + const [removed] = bots.splice(index, 1); + return structuredClone(removed); + }); + } + async clear() { + const operation = this.#writeQueue.then(async () => { + try { + await unlink4(this.#path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + this.#value = Object.freeze({ version: 2, bots: Object.freeze([]) }); + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + } + async #replaceBots(bots) { + const document = Object.freeze({ version: 2, bots: Object.freeze([...bots]) }); + const operation = this.#writeQueue.then(async () => { + await this.#writeDocument(document); + this.#value = document; + }); + 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: 2, bots: Object.freeze(bots) }); + await this.#writeDocument(document); + this.#value = document; + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + return result; + } + async #writeDocument(document) { + await mkdir3(dirname3(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.tmp`; + await writeFile3(temporary, `${JSON.stringify(document, null, 2)} +`, { encoding: "utf8", mode: 384 }); + await rename3(temporary, this.#path); + } +}; + +// src/channels/feishu/multi-bot-controller.mjs +import { randomUUID as randomUUID7 } from "node:crypto"; + +// src/channels/feishu/registration-manager.mjs +var ACTIVE_STATES = /* @__PURE__ */ new Set([ + "starting", + "qr_ready", + "polling", + "slow_down", + "domain_switched", + "saving" +]); +var SDK_POLLING_STATES = /* @__PURE__ */ new Set([ + "polling", + "slow_down", + "domain_switched" +]); +var REGISTRATION_STATES = Object.freeze({ + IDLE: "idle", + STARTING: "starting", + QR_READY: "qr_ready", + POLLING: "polling", + SLOW_DOWN: "slow_down", + DOMAIN_SWITCHED: "domain_switched", + SAVING: "saving", + SUCCEEDED: "succeeded", + EXPIRED: "expired", + CANCELLED: "cancelled", + ERROR: "error" +}); +function errorCode(error) { + if (["access_denied", "expired_token", "abort"].includes(error?.code)) return error.code; + return "registration_failed"; +} +function publicError(error) { + const code = errorCode(error); + const messages = { + access_denied: "Registration was denied.", + abort: "Registration was cancelled.", + expired_token: "The registration QR code expired." + }; + return { + code, + message: messages[code] ?? "Unable to register the Feishu app." + }; +} +function expirySeconds(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new TypeError("registerApp onQRCodeReady returned an invalid expireIn"); + } + return seconds; +} +function copyUserInfo(userInfo) { + if (userInfo === void 0) return void 0; + if (userInfo === null || typeof userInfo !== "object" || Array.isArray(userInfo)) { + throw new TypeError("registerApp returned invalid user_info"); + } + return { ...userInfo }; +} +var RegistrationManager = class { + #registerApp; + #onCredentials; + #now; + #setTimeout; + #clearTimeout; + #attempt = 0; + #active = null; + #snapshot; + constructor({ + registerApp, + onCredentials, + now = Date.now, + setTimeout: setTimeoutFn = globalThis.setTimeout, + clearTimeout: clearTimeoutFn = globalThis.clearTimeout + } = {}) { + if (typeof registerApp !== "function") { + throw new TypeError("RegistrationManager requires a registerApp function"); + } + if (typeof onCredentials !== "function") { + throw new TypeError("RegistrationManager requires an onCredentials function"); + } + if (typeof now !== "function" || typeof setTimeoutFn !== "function" || typeof clearTimeoutFn !== "function") { + throw new TypeError("RegistrationManager clock dependencies must be functions"); + } + this.#registerApp = registerApp; + this.#onCredentials = onCredentials; + this.#now = now; + this.#setTimeout = setTimeoutFn; + this.#clearTimeout = clearTimeoutFn; + this.#snapshot = this.#makeSnapshot(null, REGISTRATION_STATES.IDLE); + } + start(registerOptions = {}) { + if (registerOptions === null || typeof registerOptions !== "object" || Array.isArray(registerOptions)) { + throw new TypeError("Registration options must be an object"); + } + this.#supersedeActiveAttempt(); + const run = { + id: ++this.#attempt, + controller: new AbortController(), + qrCodeUrl: null, + expiresAt: null, + pollIntervalSeconds: null, + expiryTimer: null + }; + this.#active = run; + this.#snapshot = this.#makeSnapshot(run, REGISTRATION_STATES.STARTING); + const options = { + ...registerOptions, + signal: run.controller.signal, + onQRCodeReady: (info) => this.#onQRCodeReady(run, info), + onStatusChange: (info) => this.#onStatusChange(run, info) + }; + const registration = Promise.resolve().then(() => this.#registerApp(options)); + void registration.then( + (result) => this.#onRegistrationSucceeded(run, result), + (error) => this.#onRegistrationFailed(run, error) + ); + return this.status(); + } + status() { + this.#expireIfNeeded(); + const snapshot = { ...this.#snapshot }; + if (snapshot.error) snapshot.error = { ...snapshot.error }; + const run = this.#active; + if (run && run.expiresAt !== null && ACTIVE_STATES.has(snapshot.state)) { + snapshot.remainingSeconds = Math.max(0, Math.ceil((run.expiresAt - this.#now()) / 1e3)); + } + return snapshot; + } + cancel() { + const run = this.#active; + if (!run) return this.status(); + this.#finishRun(run, REGISTRATION_STATES.CANCELLED, { + error: { + code: "abort", + message: "Registration was cancelled." + } + }); + run.controller.abort(); + return this.status(); + } + #isCurrent(run) { + return this.#active === run; + } + #makeSnapshot(run, state, extra = {}) { + const snapshot = { + state, + attempt: run?.id ?? this.#attempt, + updatedAt: this.#now(), + ...extra + }; + if (run?.qrCodeUrl && ACTIVE_STATES.has(state)) { + snapshot.qrCodeUrl = run.qrCodeUrl; + snapshot.expiresAt = run.expiresAt; + } + if (run?.pollIntervalSeconds !== null && ACTIVE_STATES.has(state)) { + snapshot.pollIntervalSeconds = run.pollIntervalSeconds; + } + return snapshot; + } + #setRunState(run, state, extra = {}) { + if (!this.#isCurrent(run)) return; + this.#snapshot = this.#makeSnapshot(run, state, extra); + } + #onQRCodeReady(run, info) { + if (!this.#isCurrent(run)) return; + if (typeof info?.url !== "string" || !info.url) { + throw new TypeError("registerApp onQRCodeReady returned an invalid URL"); + } + const seconds = expirySeconds(info.expireIn); + run.qrCodeUrl = info.url; + run.expiresAt = this.#now() + seconds * 1e3; + this.#clearExpiryTimer(run); + run.expiryTimer = this.#setTimeout(() => this.#expireRun(run), seconds * 1e3); + run.expiryTimer?.unref?.(); + this.#setRunState(run, REGISTRATION_STATES.QR_READY); + } + #onStatusChange(run, info) { + if (!this.#isCurrent(run) || !SDK_POLLING_STATES.has(info?.status)) return; + if (info.status === REGISTRATION_STATES.SLOW_DOWN && Number.isFinite(Number(info.interval))) { + run.pollIntervalSeconds = Number(info.interval); + } + this.#setRunState(run, info.status); + } + async #onRegistrationSucceeded(run, result) { + if (!this.#isCurrent(run)) return; + const clientId = result?.client_id; + const clientSecret = result?.client_secret; + if (typeof clientId !== "string" || !clientId || typeof clientSecret !== "string" || !clientSecret) { + this.#finishRun(run, REGISTRATION_STATES.ERROR, { + error: { + code: "invalid_credentials", + message: "Feishu registration returned invalid credentials." + } + }); + return; + } + let userInfo; + try { + userInfo = copyUserInfo(result.user_info); + } catch { + this.#finishRun(run, REGISTRATION_STATES.ERROR, { + error: { + code: "invalid_credentials", + message: "Feishu registration returned invalid credentials." + } + }); + return; + } + this.#clearExpiryTimer(run); + run.qrCodeUrl = null; + run.expiresAt = null; + run.pollIntervalSeconds = null; + this.#setRunState(run, REGISTRATION_STATES.SAVING); + try { + await this.#onCredentials({ + client_id: clientId, + client_secret: clientSecret, + user_info: userInfo + }); + } catch { + if (this.#isCurrent(run)) { + this.#finishRun(run, REGISTRATION_STATES.ERROR, { + error: { + code: "credentials_callback_failed", + message: "Unable to store the Feishu credentials." + } + }); + } + return; + } + if (this.#isCurrent(run)) { + this.#finishRun(run, REGISTRATION_STATES.SUCCEEDED); + } + } + #onRegistrationFailed(run, error) { + if (!this.#isCurrent(run)) return; + const code = errorCode(error); + if (code === "expired_token") { + this.#finishRun(run, REGISTRATION_STATES.EXPIRED, { + error: publicError(error) + }); + return; + } + if (code === "abort") { + this.#finishRun(run, REGISTRATION_STATES.CANCELLED, { + error: publicError(error) + }); + return; + } + this.#finishRun(run, REGISTRATION_STATES.ERROR, { + error: publicError(error) + }); + } + #expireIfNeeded() { + const run = this.#active; + if (run && run.expiresAt !== null && this.#now() >= run.expiresAt) { + this.#expireRun(run); + } + } + #expireRun(run) { + if (!this.#isCurrent(run)) return; + this.#finishRun(run, REGISTRATION_STATES.EXPIRED, { + error: { + code: "expired_token", + message: "The registration QR code expired." + } + }); + run.controller.abort(); + } + #finishRun(run, state, extra = {}) { + if (!this.#isCurrent(run)) return; + this.#clearExpiryTimer(run); + this.#snapshot = this.#makeSnapshot(run, state, extra); + this.#active = null; + } + #clearExpiryTimer(run) { + if (run.expiryTimer !== null) { + this.#clearTimeout(run.expiryTimer); + run.expiryTimer = null; + } + } + #supersedeActiveAttempt() { + const previous = this.#active; + if (!previous) return; + this.#clearExpiryTimer(previous); + this.#active = null; + previous.controller.abort(); + } +}; + +// src/channels/feishu/plugin-controller.mjs +var REQUIRED_TENANT_SCOPES = Object.freeze([ + "im:message.p2p_msg:readonly", + "im:message.group_at_msg:readonly", + "im:message:send_as_bot", + "im:message.reactions:write_only", + "im:message:recall", + "cardkit:card:write" +]); + +// src/channels/feishu/multi-bot-controller.mjs +var ACTIVE_REGISTRATION_STATES2 = /* @__PURE__ */ new Set([ + "starting", + "qr_ready", + "polling", + "slow_down", + "domain_switched" +]); +var MUTABLE_REGISTRATION_STATES = /* @__PURE__ */ new Set([...ACTIVE_REGISTRATION_STATES2, "saving"]); +function idleConnection() { + return { + ready: false, + feishuLongConnectionState: "idle", + harnessReachable: false + }; +} +function connectionStatus(runtime) { + return runtime ? runtime.status : idleConnection(); +} +function isConnected2(connection) { + return connection.ready === true && connection.feishuLongConnectionState === "connected" && connection.harnessReachable === true; +} +function maskedAppId(appId) { + return appId.length > 12 ? `${appId.slice(0, 8)}\u2022\u2022\u2022\u2022${appId.slice(-4)}` : "cli_\u2022\u2022\u2022\u2022"; +} +function publicBot(config) { + return { + name: config.botName, + appIdMasked: maskedAppId(config.appId), + activated: config.activated, + domain: config.domain + }; +} +function botPhase({ connected, error, connection }) { + if (connected) return "connected"; + if (error || connection.feishuLongConnectionState === "failed") return "error"; + return "disconnected"; +} +function makeBotId() { + return `bot_${randomUUID7().replaceAll("-", "")}`; +} +function makeRegistrationId() { + return `reg_${randomUUID7().replaceAll("-", "")}`; +} +function secretRefFor(botId) { + return `DSH_FEISHU_APP_SECRET_${botId.slice(4).toUpperCase()}`; +} +var MultiBotDshFeishuController = class { + #registerApp; + #verifyApp; + #credentials; + #configStore; + #createRuntime; + #deleteState; + #createBotId; + #createRegistrationId; + #runtimes = /* @__PURE__ */ new Map(); + #botErrors = /* @__PURE__ */ new Map(); + #registrations = /* @__PURE__ */ new Map(); + #botOwnership = /* @__PURE__ */ new Map(); + #latestRegistrationId = null; + #configTransition = Promise.resolve(); + #botTransitions = /* @__PURE__ */ new Map(); + #revision = 1; + #closed = false; + constructor({ + registerApp, + verifyApp, + credentials, + configStore, + createRuntime, + deleteState = async () => { + }, + createBotId = makeBotId, + createRegistrationId = makeRegistrationId + }) { + if (typeof registerApp !== "function") throw new Error("registerApp is required"); + if (typeof verifyApp !== "function") throw new Error("verifyApp is required"); + if (!credentials) throw new Error("credentials service is required"); + if (!configStore || typeof configStore.list !== "function") { + throw new Error("multi-bot config store is required"); + } + if (typeof createRuntime !== "function") throw new Error("createRuntime is required"); + if (typeof deleteState !== "function") throw new Error("deleteState must be a function"); + this.#registerApp = registerApp; + this.#verifyApp = verifyApp; + this.#credentials = credentials; + this.#configStore = configStore; + this.#createRuntime = createRuntime; + this.#deleteState = deleteState; + this.#createBotId = createBotId; + this.#createRegistrationId = createRegistrationId; + } + async initialize() { + if (this.#closed) return this.status(); + const bots = this.#configStore.list(); + let attempted = false; + await Promise.allSettled(bots.map((config) => this.#withBotTransition(config.id, async () => { + const current = connectionStatus(this.#runtimes.get(config.id)); + if (isConnected2(current) || current.feishuLongConnectionState === "connecting" || current.feishuLongConnectionState === "reconnecting") { + return; + } + attempted = true; + if (config.deletionPending) { + this.#botErrors.set(config.id, { + code: "deletion_pending", + message: "\u673A\u5668\u4EBA\u6B63\u5728\u7B49\u5F85\u5B8C\u6210\u672C\u5730\u5220\u9664\uFF0C\u8BF7\u91CD\u8BD5\u79FB\u9664\u3002" + }); + return; + } + let resolved; + try { + resolved = await this.#credentials.resolve(config.secretRef); + } catch { + this.#botErrors.set(config.id, { + code: "missing_credentials", + message: "\u65E0\u6CD5\u8BFB\u53D6\u673A\u5668\u4EBA\u51ED\u636E\uFF0C\u8BF7\u68C0\u67E5\u51ED\u636E\u5B58\u50A8\u3002" + }); + return; + } + if (!resolved?.value) { + this.#botErrors.set(config.id, { + code: "missing_credentials", + message: "\u673A\u5668\u4EBA\u51ED\u636E\u7F3A\u5931\uFF0C\u8BF7\u5220\u9664\u540E\u91CD\u65B0\u626B\u7801\u63A5\u5165\u3002" + }); + return; + } + try { + await this.#startRuntime(config, resolved.value); + this.#botErrors.delete(config.id); + } catch { + this.#botErrors.set(config.id, { + code: "connection_failed", + message: "\u673A\u5668\u4EBA\u6682\u65F6\u65E0\u6CD5\u8FDE\u63A5\u98DE\u4E66\uFF0C\u8BF7\u91CD\u8BD5\u3002" + }); + } + }))); + if (attempted) this.#touch(); + return this.status(); + } + startRegistration() { + this.#assertOpen(); + const id = this.#createRegistrationId(); + if (typeof id !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || this.#registrations.has(id)) { + throw new Error("Registration id generator returned an invalid or duplicate id"); + } + const record = { id, manager: null, botId: null, createdNew: false, cancelled: false }; + record.manager = new RegistrationManager({ + registerApp: this.#registerApp, + onCredentials: (result) => this.#serializeConfig(() => this.#acceptCredentials(record, result)) + }); + this.#registrations.set(id, record); + this.#latestRegistrationId = id; + this.#trimRegistrations(); + record.manager.start({ + source: "deepseek-harness", + createOnly: true, + appPreset: { + name: "{user} \u7684\u5317\u6C47\u661F\u6CB3 AI \u52A9\u624B", + desc: "\u8FDE\u63A5\u98DE\u4E66\u4E0E DeepSeek Harness\uFF0C\u5728\u804A\u5929\u4E2D\u4F7F\u7528\u4F01\u4E1A AI \u52A9\u624B\u3002" + }, + addons: { + preset: false, + scopes: { tenant: [...REQUIRED_TENANT_SCOPES] }, + events: { items: { tenant: ["im.message.receive_v1"] } } + } + }); + this.#touch(); + return this.registrationStatus(id); + } + hasRegistration(attemptId) { + return this.#registrations.has(attemptId); + } + registrationStatus(attemptId) { + const record = this.#registrations.get(attemptId); + if (!record) return null; + return this.#status({ registration: record, selectedBotId: record.botId }); + } + async cancelRegistration(attemptId = this.#latestRegistrationId) { + const record = this.#registrations.get(attemptId); + if (!record) return this.status(); + if (!MUTABLE_REGISTRATION_STATES.has(record.manager.status().state)) { + return this.registrationStatus(attemptId); + } + record.cancelled = true; + record.manager.cancel(); + await this.#serializeConfig(async () => { + if (record.createdNew && record.botId && this.#botOwnership.get(record.botId) === record.id && this.#configStore.getBot(record.botId)) { + await this.#withBotTransition(record.botId, () => this.#deleteBot(record.botId)); + } + }); + this.#touch(); + return this.registrationStatus(attemptId) ?? this.status(); + } + status(botId) { + return this.#status({ + registration: this.#registrations.get(this.#latestRegistrationId) ?? null, + selectedBotId: botId + }); + } + async reconnectBot(botId) { + this.#assertOpen(); + return this.#withBotTransition(botId, async () => { + const config = this.#requireBot(botId); + if (config.deletionPending) { + this.#botErrors.set(botId, { + code: "deletion_pending", + message: "\u673A\u5668\u4EBA\u6B63\u5728\u7B49\u5F85\u5B8C\u6210\u672C\u5730\u5220\u9664\uFF0C\u8BF7\u91CD\u8BD5\u79FB\u9664\u3002" + }); + return this.status(botId); + } + if (isConnected2(connectionStatus(this.#runtimes.get(botId)))) { + return this.status(botId); + } + let resolved; + try { + resolved = await this.#credentials.resolve(config.secretRef); + } catch { + resolved = null; + } + if (!resolved?.value) { + this.#botErrors.set(botId, { + code: "missing_credentials", + message: "\u673A\u5668\u4EBA\u51ED\u636E\u7F3A\u5931\uFF0C\u8BF7\u5220\u9664\u540E\u91CD\u65B0\u626B\u7801\u63A5\u5165\u3002" + }); + this.#touch(); + return this.status(botId); + } + try { + await this.#startRuntime(config, resolved.value); + this.#botErrors.delete(botId); + } catch { + this.#botErrors.set(botId, { + code: "connection_failed", + message: "\u673A\u5668\u4EBA\u6682\u65F6\u65E0\u6CD5\u8FDE\u63A5\u98DE\u4E66\uFF0C\u8BF7\u91CD\u8BD5\u3002" + }); + } + this.#touch(); + return this.status(botId); + }); + } + async disconnectBot(botId) { + this.#assertOpen(); + return this.#withBotTransition(botId, async () => { + this.#requireBot(botId); + await this.#stopRuntime(botId); + this.#botErrors.delete(botId); + this.#touch(); + return this.status(botId); + }); + } + async deleteBot(botId) { + this.#assertOpen(); + return this.#serializeConfig(() => this.#withBotTransition(botId, async () => { + this.#requireBot(botId); + await this.#deleteBot(botId); + this.#touch(); + return this.status(); + })); + } + // Compatibility methods for the original one-bot browser contract. + async reconnect() { + const bot = this.#configStore.list()[0]; + return bot ? this.reconnectBot(bot.id) : this.status(); + } + async disconnect() { + const bot = this.#configStore.list()[0]; + return bot ? this.deleteBot(bot.id) : this.status(); + } + async close() { + if (this.#closed) return; + this.#closed = true; + for (const record of this.#registrations.values()) { + if (MUTABLE_REGISTRATION_STATES.has(record.manager.status().state)) { + record.cancelled = true; + record.manager.cancel(); + } + } + await this.#configTransition; + await Promise.allSettled([...this.#botTransitions.values()]); + await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id))); + } + #status({ registration, selectedBotId } = {}) { + const bots = this.#configStore.list().map((config) => { + const connection = connectionStatus(this.#runtimes.get(config.id)); + const connected = isConnected2(connection); + const error = this.#botErrors.get(config.id) ?? null; + return { + botId: config.id, + phase: botPhase({ connected, error, connection }), + connected, + configured: true, + bot: publicBot(config), + connection, + error + }; + }); + const registrationSnapshot = registration ? this.#registrationSnapshot(registration) : { + state: "idle", + attempt: 0, + updatedAt: Date.now() + }; + const registering = ACTIVE_REGISTRATION_STATES2.has(registrationSnapshot.state); + const connecting = registrationSnapshot.state === "saving"; + const registrationOwnsProjection = Boolean(registration) && (registering || connecting); + const selected = bots.find((bot) => bot.botId === selectedBotId) ?? (registrationOwnsProjection ? null : bots[0] ?? null); + const aggregateConnected = bots.some((bot) => bot.connected); + let phase = selected?.phase ?? "unconfigured"; + if (registering) phase = "registering"; + else if (connecting) phase = "connecting"; + else if (registrationSnapshot.state === "error" && !selected) phase = "error"; + return { + schemaVersion: 2, + revision: this.#revision, + phase, + connected: selected?.connected ?? false, + configured: bots.length > 0, + bot: selected?.bot ?? null, + connection: selected?.connection ?? idleConnection(), + error: selected?.error ?? registrationSnapshot.error ?? null, + registration: registrationSnapshot, + bots, + totals: { + configured: bots.length, + connected: bots.filter((bot) => bot.connected).length + }, + anyConnected: aggregateConnected + }; + } + #registrationSnapshot(record) { + const snapshot = record.manager.status(); + return { + ...snapshot, + attempt: record.id, + ...record.botId ? { botId: record.botId } : {} + }; + } + async #acceptCredentials(record, result) { + if (record.cancelled) throw new Error("Registration was cancelled"); + const appId = result.client_id; + const appSecret = result.client_secret; + const ownerOpenId = result.user_info?.open_id; + const domain = result.user_info?.tenant_brand === "lark" ? "lark" : "feishu"; + if (!ownerOpenId) throw new Error("Feishu registration returned no owner open_id"); + const bot = await this.#verifyApp({ appId, appSecret, domain }); + if (record.cancelled) throw new Error("Registration was cancelled"); + const existing = this.#configStore.list().find((candidate) => candidate.appId === appId); + const botId = existing?.id ?? this.#createBotId(); + if (typeof botId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(botId) || !existing && this.#configStore.getBot(botId)) { + throw new Error("Bot id generator returned an invalid or duplicate id"); + } + const secretRef = existing?.secretRef ?? secretRefFor(botId); + const previousOwnership = this.#botOwnership.get(botId); + const previousSecret = await this.#credentials.resolve(secretRef).catch(() => void 0); + await this.#credentials.set(secretRef, appSecret); + let config; + try { + config = await this.#configStore.saveBot({ + ...existing, + id: botId, + appId, + secretRef, + ownerOpenIds: [.../* @__PURE__ */ new Set([...existing?.ownerOpenIds ?? [], ownerOpenId])], + domain, + botName: bot.name, + botOpenId: bot.openId, + activated: bot.activated, + deletionPending: false, + connectedAt: (/* @__PURE__ */ new Date()).toISOString(), + createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString() + }); + record.botId = botId; + record.createdNew = !existing; + this.#botOwnership.set(botId, record.id); + } catch (error) { + try { + await this.#restoreCredential(secretRef, previousSecret); + } catch (restoreError) { + throw new Error("Unable to restore the Feishu credential after a config failure.", { + cause: restoreError + }); + } + throw error; + } + if (record.cancelled) { + if (record.createdNew) { + await this.#withBotTransition(botId, () => this.#deleteBot(botId)); + } else { + await this.#configStore.saveBot(existing); + await this.#restoreCredential(secretRef, previousSecret); + if (previousOwnership) this.#botOwnership.set(botId, previousOwnership); + else this.#botOwnership.delete(botId); + } + throw new Error("Registration was cancelled"); + } + let cancellationRolledBack = false; + try { + await this.#withBotTransition(botId, () => this.#startRuntime(config, appSecret)); + if (record.cancelled) { + if (record.createdNew) { + await this.#withBotTransition(botId, () => this.#deleteBot(botId)); + } else { + await this.#configStore.saveBot(existing); + await this.#restoreCredential(secretRef, previousSecret); + if (previousOwnership) this.#botOwnership.set(botId, previousOwnership); + else this.#botOwnership.delete(botId); + if (previousSecret?.value && !existing.deletionPending) { + await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value)); + } else { + await this.#withBotTransition(botId, () => this.#stopRuntime(botId)); + } + } + cancellationRolledBack = true; + throw new Error("Registration was cancelled"); + } + this.#botErrors.delete(botId); + this.#touch(); + } catch (error) { + if (record.cancelled) { + if (!cancellationRolledBack && record.createdNew && this.#configStore.getBot(botId)) { + await this.#withBotTransition(botId, () => this.#deleteBot(botId)); + } else if (!cancellationRolledBack && existing) { + await this.#configStore.saveBot(existing); + await this.#restoreCredential(secretRef, previousSecret); + if (previousOwnership) this.#botOwnership.set(botId, previousOwnership); + else this.#botOwnership.delete(botId); + if (!this.#closed && previousSecret?.value && !existing.deletionPending) { + await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value)); + } else { + await this.#withBotTransition(botId, () => this.#stopRuntime(botId)); + } + } + this.#touch(); + throw error; + } + if (existing && previousSecret?.value) { + try { + await this.#configStore.saveBot(existing); + await this.#restoreCredential(secretRef, previousSecret); + if (previousOwnership) this.#botOwnership.set(botId, previousOwnership); + else this.#botOwnership.delete(botId); + if (!existing.deletionPending) { + await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value)); + this.#botErrors.delete(botId); + } else { + await this.#withBotTransition(botId, () => this.#stopRuntime(botId)); + this.#botErrors.set(botId, { + code: "deletion_pending", + message: "\u673A\u5668\u4EBA\u6B63\u5728\u7B49\u5F85\u5B8C\u6210\u672C\u5730\u5220\u9664\uFF0C\u8BF7\u91CD\u8BD5\u79FB\u9664\u3002" + }); + } + this.#touch(); + throw error; + } catch (restoreError) { + if (restoreError === error) throw error; + this.#botErrors.set(botId, { + code: "connection_failed", + message: "\u673A\u5668\u4EBA\u8FDE\u63A5\u66F4\u65B0\u5931\u8D25\uFF0C\u4E14\u539F\u8FDE\u63A5\u65E0\u6CD5\u6062\u590D\uFF0C\u8BF7\u91CD\u8BD5\u3002" + }); + this.#touch(); + throw new Error("Unable to restore the previous Feishu bot connection.", { + cause: restoreError + }); + } + } + this.#botErrors.set(botId, { + code: "connection_failed", + message: "\u673A\u5668\u4EBA\u5DF2\u7ECF\u521B\u5EFA\uFF0C\u4F46\u957F\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u8BF7\u70B9\u51FB\u91CD\u8BD5\u3002" + }); + this.#touch(); + throw error; + } + } + async #startRuntime(config, appSecret) { + await this.#stopRuntime(config.id); + const runtime = await this.#createRuntime({ + botId: config.id, + config, + appSecret + }); + this.#runtimes.set(config.id, runtime); + try { + await runtime.start(); + } catch (error) { + if (this.#runtimes.get(config.id) === runtime) this.#runtimes.delete(config.id); + await runtime.stop({ preserveError: true }).catch(() => void 0); + throw error; + } + } + async #stopRuntime(botId) { + const runtime = this.#runtimes.get(botId); + this.#runtimes.delete(botId); + if (runtime) await runtime.stop(); + } + async #deleteBot(botId) { + let config = this.#configStore.getBot(botId); + if (!config) return; + if (!config.deletionPending) { + config = await this.#configStore.saveBot({ ...config, deletionPending: true }); + } + await this.#stopRuntime(botId); + try { + await this.#credentials.unset(config.secretRef); + } catch (error) { + this.#botErrors.set(botId, { + code: "credential_removal_failed", + message: "\u65E0\u6CD5\u5220\u9664\u673A\u5668\u4EBA\u51ED\u636E\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" + }); + throw new Error("Unable to remove the Feishu credential.", { cause: error }); + } + try { + await this.#deleteState({ botId, config }); + } catch (error) { + this.#botErrors.set(botId, { + code: "state_cleanup_failed", + message: "\u65E0\u6CD5\u5220\u9664\u673A\u5668\u4EBA\u7684\u672C\u5730\u4F1A\u8BDD\u6570\u636E\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" + }); + throw new Error("Unable to remove the Feishu bot session state.", { cause: error }); + } + await this.#configStore.removeBot(botId); + this.#botErrors.delete(botId); + this.#botOwnership.delete(botId); + } + async #restoreCredential(secretRef, previous) { + if (previous?.value) await this.#credentials.set(secretRef, previous.value); + else await this.#credentials.unset(secretRef); + } + #requireBot(botId) { + const config = this.#configStore.getBot(botId); + if (!config) throw new Error("Unknown Feishu bot"); + return config; + } + #assertOpen() { + if (this.#closed) throw new Error("The Feishu controller is closed"); + } + #serializeConfig(operation) { + const result = this.#configTransition.then(operation, operation); + this.#configTransition = result.then(() => void 0, () => void 0); + return result; + } + #withBotTransition(botId, operation) { + const previous = this.#botTransitions.get(botId) ?? Promise.resolve(); + const result = previous.then(operation, operation); + const tail = result.then(() => void 0, () => void 0); + this.#botTransitions.set(botId, tail); + void tail.finally(() => { + if (this.#botTransitions.get(botId) === tail) this.#botTransitions.delete(botId); + }); + return result; + } + #trimRegistrations() { + if (this.#registrations.size <= 32) return; + for (const [id, record] of this.#registrations) { + if (id === this.#latestRegistrationId) continue; + const state = record.manager.status().state; + if (!ACTIVE_REGISTRATION_STATES2.has(state) && state !== "saving") { + this.#registrations.delete(id); + } + if (this.#registrations.size <= 32) break; + } + } + #touch() { + this.#revision += 1; + } +}; + +// src/channels/feishu/state-store.mjs +import { mkdir as mkdir4, readFile as readFile4, rename as rename4, writeFile as writeFile4 } from "node:fs/promises"; +import { dirname as dirname4 } from "node:path"; +var EMPTY_STATE2 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); +var StateStore = class { + #path; + #state = structuredClone(EMPTY_STATE2); + #writeQueue = Promise.resolve(); + constructor(path) { + this.#path = path; + } + async load() { + try { + const parsed = JSON.parse(await readFile4(this.#path, "utf8")); + this.#state = { + version: 1, + sessions: parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {}, + seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1e3) : [] + }; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + 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(); + } + snapshot() { + return structuredClone(this.#state); + } + async #persist() { + const snapshot = JSON.stringify(this.#state, null, 2) + "\n"; + this.#writeQueue = this.#writeQueue.then(async () => { + await mkdir4(dirname4(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.tmp`; + await writeFile4(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename4(temporary, this.#path); + }); + await this.#writeQueue; + } +}; + +// plugin-src/host/channels/feishu/production.mjs +function harnessOrigin2(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-feishu requires an initialized DSH webServer port"); + } + return new URL(`http://127.0.0.1:${port}`); +} +function pluginPaths2(config) { + const dshHome = resolve2(config.dshHome ?? process.env.DSH_HOME ?? join2(homedir2(), ".dsh")); + const root = resolve2(config.dataDir ?? join2(dshHome, "integrations", "dsh-feishu")); + return { + root, + config: resolve2(config.configPath ?? join2(root, "config.json")), + legacyState: resolve2(config.statePath ?? join2(root, "state.json")), + bots: resolve2(config.botsDir ?? join2(root, "bots")) + }; +} +async function createProductionController2(ctx, config = {}, internals = {}) { + if (!ctx?.credentials) throw new TypeError("dsh-feishu requires ctx.credentials"); + if (!ctx?.webServer) throw new TypeError("dsh-feishu requires ctx.webServer"); + const lark = internals.lark ?? Lark; + const Controller = internals.Controller ?? MultiBotDshFeishuController; + const ConfigStore = internals.ConfigStore ?? PluginConfigStore; + const SessionStateStore = internals.StateStore ?? StateStore; + const Harness = internals.HarnessClient ?? HarnessClient2; + const Runtime = internals.FeishuRuntime ?? FeishuRuntime; + const verifyApp = internals.verifyFeishuApp ?? verifyFeishuApp; + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor2; + const logger = typeof ctx.logger === "function" ? ctx.logger("dsh-feishu") : ctx.logger ?? console; + const paths = pluginPaths2(config); + const configStore = await new ConfigStore(paths.config).load(); + const stateStores = /* @__PURE__ */ new Map(); + const statePathFor = (botConfig) => !botConfig.id || !botConfig.secretRef || botConfig.secretRef === LEGACY_FEISHU_SECRET_REF ? paths.legacyState : resolve2(paths.bots, botConfig.id, "state.json"); + const stateFor = async (botConfig) => { + const stateKey = botConfig.id ?? "__legacy__"; + let state = stateStores.get(stateKey); + if (!state) { + state = await new SessionStateStore(statePathFor(botConfig)).load(); + stateStores.set(stateKey, state); + } + return state; + }; + const harness = new Harness({ + baseUrl: harnessOrigin2(ctx.webServer, config.harnessBaseUrl), + workspace: resolve2(config.workspace ?? process.cwd()), + agentPreset: config.agentPreset ?? "standard", + // This plugin is already hosted by a running DSH process. Starting a + // second DSH would create a competing server and lifecycle. + autostart: false, + dshBin: config.dshBin ?? "dsh" + }); + const controller = new Controller({ + registerApp: (options) => lark.registerApp(options), + verifyApp, + credentials: ctx.credentials, + configStore, + createRuntime: async ({ botId, config: botConfig, appSecret }) => { + const state = await stateFor(botConfig); + return new Runtime({ + lark, + appId: botConfig.appId, + appSecret, + domain: botConfig.domain, + ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId], + harness, + state, + replyTimeoutMs: config.replyTimeoutMs ?? 6e5, + logger: { + error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args), + warn: (...args) => logger.warn?.(`[${botId ?? botConfig.id}]`, ...args), + info: (...args) => logger.info?.(`[${botId ?? botConfig.id}]`, ...args), + debug: (...args) => logger.debug?.(`[${botId ?? botConfig.id}]`, ...args) + } + }); + }, + deleteState: async ({ botId, config: botConfig }) => { + stateStores.delete(botId); + try { + await unlink5(statePathFor(botConfig)); + } 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/feishu/rpc.mjs +import QRCode2 from "qrcode"; + +// plugin-src/client/channels/feishu/api.js +var FEISHU_RPC_CHANNEL = "/feishu"; +var FEISHU_ENDPOINTS = Object.freeze({ + status: "connection.status", + beginProvisioning: "provision.begin", + pollProvisioning: "provision.poll", + cancelProvisioning: "provision.cancel", + reconnectBot: "bot.reconnect", + disconnectBot: "bot.disconnect", + deleteBot: "bot.delete", + // Kept for rolling upgrades. The multi-bot UI never calls these endpoints. + testConnection: "connection.test", + disconnect: "connection.disconnect" +}); + +// plugin-src/host/channels/feishu/rpc.mjs +var FEISHU_MULTI_ENDPOINTS = Object.freeze({ + reconnectBot: "bot.reconnect", + disconnectBot: "bot.disconnect", + deleteBot: "bot.delete" +}); +var FEISHU_RPC_ENDPOINTS = Object.freeze([ + .../* @__PURE__ */ new Set([...Object.values(FEISHU_ENDPOINTS), ...Object.values(FEISHU_MULTI_ENDPOINTS)]) +]); +var REGISTRATION_STATES2 = /* @__PURE__ */ new Set([ + "idle", + "starting", + "qr_ready", + "polling", + "slow_down", + "domain_switched", + "saving", + "succeeded", + "expired", + "cancelled", + "error" +]); +var SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/; +var PUBLIC_ERROR_MESSAGES = Object.freeze({ + abort: "Registration was cancelled.", + access_denied: "Registration was denied.", + expired_token: "The registration QR code expired.", + invalid_credentials: "Feishu returned invalid app credentials.", + credentials_callback_failed: "Unable to activate the Feishu connection.", + registration_failed: "Unable to register the Feishu app.", + connection_failed: "The bot was created, but its connection could not be started.", + credential_removal_failed: "Unable to remove the Feishu credentials.", + state_cleanup_failed: "Unable to remove the bot session data. Please retry.", + deletion_pending: "Bot deletion is incomplete. Retry removal to finish cleanup.", + missing_credentials: "The bot credentials are missing. Delete it and scan again." +}); +var POLL_STATUS_BY_REGISTRATION = Object.freeze({ + idle: "pending", + starting: "pending", + qr_ready: "pending", + polling: "pending", + slow_down: "pending", + domain_switched: "pending", + saving: "connecting", + succeeded: "connected", + expired: "expired", + cancelled: "failed", + error: "failed" +}); +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} +function hasOnlyKeys(value, allowed) { + return isPlainObject(value) && Reflect.ownKeys(value).every((key) => typeof key === "string" && allowed.has(key)); +} +function finiteNumber(value) { + return Number.isFinite(value) ? value : void 0; +} +function safeOpaqueId(value) { + return typeof value === "string" && SAFE_ID.test(value); +} +function publicError2(error) { + if (!error || typeof error !== "object") return null; + const code = typeof error.code === "string" && Object.hasOwn(PUBLIC_ERROR_MESSAGES, error.code) ? error.code : "registration_failed"; + return { code, message: PUBLIC_ERROR_MESSAGES[code] }; +} +function publicRegistration(registration) { + if (!registration || typeof registration !== "object") return { state: "idle", attempt: 0 }; + const state = REGISTRATION_STATES2.has(registration.state) ? registration.state : "error"; + const attempt = safeOpaqueId(registration.attempt) ? registration.attempt : finiteNumber(registration.attempt) ?? 0; + const result = { state, attempt }; + const updatedAt = finiteNumber(registration.updatedAt); + const expiresAt = finiteNumber(registration.expiresAt); + const remainingSeconds = finiteNumber(registration.remainingSeconds); + const pollIntervalSeconds = finiteNumber(registration.pollIntervalSeconds); + if (updatedAt !== void 0) result.updatedAt = updatedAt; + if (typeof registration.qrCodeUrl === "string" && registration.qrCodeUrl.length > 0) { + result.qrCodeUrl = registration.qrCodeUrl; + } + if (expiresAt !== void 0) result.expiresAt = expiresAt; + if (remainingSeconds !== void 0) result.remainingSeconds = remainingSeconds; + if (pollIntervalSeconds !== void 0) result.pollIntervalSeconds = pollIntervalSeconds; + if (safeOpaqueId(registration.botId)) result.botId = registration.botId; + const error = publicError2(registration.error); + if (error) result.error = error; + return result; +} +function connectionFacts(connection) { + const source = connection && typeof connection === "object" ? connection : {}; + const connected = source.connected === true || source.ready === true && source.feishuLongConnectionState === "connected" && source.harnessReachable === true; + return { + connected, + ready: source.ready === true, + harnessReachable: source.harnessReachable === true + }; +} +function publicBot2(bot) { + const source = bot && typeof bot === "object" ? bot : {}; + const result = { + name: typeof source.name === "string" && source.name.length > 0 ? source.name : "\u98DE\u4E66\u673A\u5668\u4EBA" + }; + if (typeof source.avatarUrl === "string") result.avatarUrl = source.avatarUrl; + if (typeof source.appIdMasked === "string") result.appIdMasked = source.appIdMasked; + if (typeof source.tenantName === "string") result.tenantName = source.tenantName; + if (source.domain === "feishu" || source.domain === "lark") result.domain = source.domain; + if (typeof source.activated === "boolean" || typeof source.activated === "number") { + result.activated = source.activated; + } + return result; +} +function publicHealth(status, connected) { + if (connected) return { status: "healthy", summary: "\u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38", lastCheckedAt: Date.now() }; + if (status?.configured === true) { + return { status: "offline", summary: "\u673A\u5668\u4EBA\u5C1A\u672A\u8FDE\u63A5", lastCheckedAt: Date.now() }; + } + return { status: "offline", summary: "\u5C1A\u672A\u63A5\u5165\u98DE\u4E66\u673A\u5668\u4EBA", lastCheckedAt: Date.now() }; +} +function connectionState(status, registration, connected) { + if (connected) return "connected"; + if (status?.phase === "error" || registration.state === "error") return "error"; + if (status?.phase === "connecting" || registration.state === "saving") return "connecting"; + if (status?.phase === "registering" || ["starting", "qr_ready", "polling", "slow_down", "domain_switched"].includes(registration.state)) { + return "provisioning"; + } + return "disconnected"; +} +async function qrCodeDataUrl(verificationUrl) { + return QRCode2.toDataURL(verificationUrl, { + errorCorrectionLevel: "M", + margin: 1, + width: 320, + type: "image/png" + }); +} +async function publicProvisioning(registration, encodeQr) { + if (!registration.qrCodeUrl) return void 0; + return { + attemptId: String(registration.attempt), + verificationUrl: registration.qrCodeUrl, + qrCodeDataUrl: await encodeQr(registration.qrCodeUrl), + expiresAt: registration.expiresAt ?? Date.now() + 5 * 6e4, + pollIntervalMs: Math.max(800, Math.min(1e4, (registration.pollIntervalSeconds ?? 1.8) * 1e3)) + }; +} +function publicBotEntry(entry) { + const source = entry && typeof entry === "object" ? entry : {}; + if (!safeOpaqueId(source.botId)) return null; + const facts = connectionFacts(source.connection); + const connected = source.connected === true || facts.connected; + const registration = { state: "idle" }; + const result = { + botId: source.botId, + state: connectionState(source, registration, connected), + connected, + configured: source.configured === true, + bot: publicBot2(source.bot), + health: publicHealth(source, connected) + }; + const error = publicError2(source.error); + if (error) result.error = error; + return result; +} +async function toPublicFeishuStatus(status, { encodeQr = qrCodeDataUrl } = {}) { + const source = status && typeof status === "object" ? status : {}; + const registration = publicRegistration(source.registration); + const facts = connectionFacts(source.connection); + const connected = source.connected === true || facts.connected; + const provisioning = await publicProvisioning(registration, encodeQr); + const error = publicError2(source.error) ?? registration.error ?? null; + const bots = Array.isArray(source.bots) ? source.bots.map(publicBotEntry).filter(Boolean) : []; + const snapshot = { + schemaVersion: source.schemaVersion === 2 ? 2 : 1, + revision: Number.isSafeInteger(source.revision) && source.revision >= 0 ? source.revision : 0, + state: connectionState(source, registration, connected), + connected, + configured: source.configured === true, + bot: publicBot2(source.bot), + health: publicHealth(source, connected), + bots, + totals: { + configured: bots.length || (source.configured === true ? 1 : 0), + connected: bots.length ? bots.filter((bot) => bot.connected).length : connected ? 1 : 0 + } + }; + if (provisioning) snapshot.provisioning = provisioning; + if (error) snapshot.error = error; + return snapshot; +} +function badRequest2(message) { + return { ok: false, error: { code: "bad-request", message, details: { issues: [] } } }; +} +function cancelled2() { + return { ok: false, error: { code: "cancelled", message: "The Feishu request was cancelled.", details: {} } }; +} +function internalFailure2() { + return { ok: false, error: { code: "internal", message: "The Feishu integration operation failed.", details: {} } }; +} +function validPayload(endpoint, payload) { + if (endpoint === FEISHU_ENDPOINTS.status) { + return hasOnlyKeys(payload, /* @__PURE__ */ new Set()) ? null : "This endpoint accepts an empty payload only."; + } + if (endpoint === FEISHU_ENDPOINTS.testConnection) { + return hasOnlyKeys(payload, /* @__PURE__ */ new Set()) ? null : "This endpoint accepts an empty payload only."; + } + if (endpoint === FEISHU_ENDPOINTS.beginProvisioning) { + if (!hasOnlyKeys(payload, /* @__PURE__ */ new Set(["locale", "replaceAttemptId"]))) { + return "Provisioning accepts locale and replaceAttemptId only."; + } + if (payload.locale !== void 0 && payload.locale !== "zh-CN") return "The provisioning locale must be zh-CN."; + if (payload.replaceAttemptId !== void 0 && !safeOpaqueId(payload.replaceAttemptId)) { + return "replaceAttemptId must be a valid opaque id."; + } + return null; + } + if (endpoint === FEISHU_ENDPOINTS.pollProvisioning || endpoint === FEISHU_ENDPOINTS.cancelProvisioning) { + return hasOnlyKeys(payload, /* @__PURE__ */ new Set(["attemptId"])) && safeOpaqueId(payload.attemptId) ? null : "A single valid attemptId is required."; + } + if (endpoint === FEISHU_ENDPOINTS.disconnect) { + return hasOnlyKeys(payload, /* @__PURE__ */ new Set(["removeCredentials"])) && payload.removeCredentials === true ? null : "Disconnect requires removeCredentials=true."; + } + if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot || endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) { + return hasOnlyKeys(payload, /* @__PURE__ */ new Set(["botId"])) && safeOpaqueId(payload.botId) ? null : "A single valid botId is required."; + } + if (endpoint === FEISHU_MULTI_ENDPOINTS.deleteBot) { + return hasOnlyKeys(payload, /* @__PURE__ */ new Set(["botId", "confirm"])) && safeOpaqueId(payload.botId) && payload.confirm === true ? null : "Deleting a bot requires a valid botId and confirm=true."; + } + return "Unknown Feishu endpoint."; +} +function abortableDelay2(milliseconds, signal) { + return new Promise((resolve4, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("aborted")); + return; + } + const timer = setTimeout(done, milliseconds); + timer.unref?.(); + function done() { + signal?.removeEventListener("abort", aborted); + resolve4(); + } + function aborted() { + clearTimeout(timer); + reject(signal.reason ?? new Error("aborted")); + } + signal?.addEventListener("abort", aborted, { once: true }); + }); +} +async function statusForRegistration(controller, attemptId) { + if (typeof controller.registrationStatus === "function") { + return controller.registrationStatus(attemptId); + } + return controller.status(); +} +async function waitForQr(controller, initial, attemptId, signal) { + let current = initial; + const deadline = Date.now() + 15e3; + for (; ; ) { + const registration = publicRegistration(current?.registration); + if (registration.qrCodeUrl) return current; + if (["error", "expired", "cancelled"].includes(registration.state)) { + throw new Error("Provisioning stopped before the QR code was ready."); + } + if (Date.now() >= deadline) throw new Error("Provisioning QR code timed out."); + await abortableDelay2(50, signal); + current = await statusForRegistration(controller, attemptId); + if (!current) throw new Error("The provisioning attempt is no longer active."); + } +} +function sameAttempt(status, attemptId) { + return String(publicRegistration(status?.registration).attempt) === attemptId; +} +function pollStatus(status) { + const registration = publicRegistration(status?.registration); + if (registration.state === "succeeded") { + const connected = registration.botId && (status?.connected === true || connectionFacts(status?.connection).connected); + return connected ? "connected" : "connecting"; + } + return POLL_STATUS_BY_REGISTRATION[registration.state] ?? "failed"; +} +function assertController2(controller) { + if (!controller || typeof controller.status !== "function" || typeof controller.startRegistration !== "function" || typeof controller.cancelRegistration !== "function" || typeof controller.disconnect !== "function") { + throw new TypeError("A Feishu controller with status/start/cancel/disconnect is required"); + } +} +function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl } = {}) { + assertController2(controller); + const qrCache = /* @__PURE__ */ new Map(); + const attemptQr = /* @__PURE__ */ new Map(); + const cachedEncodeQr = (url) => { + let encoded = qrCache.get(url); + if (!encoded) { + if (qrCache.size >= 32) 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 cancelled2(); + if (!FEISHU_RPC_ENDPOINTS.includes(endpoint)) return badRequest2("Unknown Feishu endpoint."); + const payloadFailure3 = validPayload(endpoint, payload); + if (payloadFailure3) return badRequest2(payloadFailure3); + try { + let value; + if (endpoint === FEISHU_ENDPOINTS.status) { + value = await toPublicFeishuStatus(await controller.status(), { encodeQr: cachedEncodeQr }); + } else if (endpoint === FEISHU_ENDPOINTS.beginProvisioning) { + if (payload.replaceAttemptId) { + await controller.cancelRegistration(payload.replaceAttemptId); + } + const started = await controller.startRegistration({ locale: payload.locale }); + const attemptId = String(publicRegistration(started?.registration).attempt); + const ready = await waitForQr(controller, started, attemptId, signal); + value = (await toPublicFeishuStatus(ready, { encodeQr: cachedEncodeQr })).provisioning; + if (!value) throw new Error("Provisioning did not produce a QR code."); + attemptQr.set(attemptId, value.verificationUrl); + } else if (endpoint === FEISHU_ENDPOINTS.pollProvisioning) { + const current = await statusForRegistration(controller, payload.attemptId); + if (!current || !sameAttempt(current, payload.attemptId)) { + return badRequest2("The provisioning attempt is no longer active."); + } + const registration = publicRegistration(current.registration); + const connection = await toPublicFeishuStatus(current, { encodeQr: cachedEncodeQr }); + value = { + status: pollStatus(current), + ...registration.botId ? { botId: registration.botId } : {}, + ...connection.provisioning ? { provisioning: connection.provisioning } : {}, + ...registration.botId && connection.connected ? { connection } : {}, + ...connection.error ? { message: connection.error.message } : {} + }; + if (["connected", "expired", "failed"].includes(value.status)) { + const url = attemptQr.get(payload.attemptId); + if (url) qrCache.delete(url); + attemptQr.delete(payload.attemptId); + } + } else if (endpoint === FEISHU_ENDPOINTS.cancelProvisioning) { + const current = await statusForRegistration(controller, payload.attemptId); + if (!current || !sameAttempt(current, payload.attemptId)) { + return badRequest2("The provisioning attempt is no longer active."); + } + const multi = typeof controller.registrationStatus === "function"; + const registration = publicRegistration(current.registration); + if (!multi && registration.state === "saving") await controller.disconnect(); + else await controller.cancelRegistration(payload.attemptId); + const url = attemptQr.get(payload.attemptId); + if (url) qrCache.delete(url); + attemptQr.delete(payload.attemptId); + value = { status: "failed", message: "Registration was cancelled." }; + } else if (endpoint === FEISHU_ENDPOINTS.testConnection) { + const current = await controller.status(); + const alreadyConnected = current?.connected === true || connectionFacts(current?.connection).connected; + const checked = alreadyConnected || typeof controller.reconnect !== "function" ? current : await controller.reconnect(); + value = await toPublicFeishuStatus(checked, { encodeQr: cachedEncodeQr }); + } else if (endpoint === FEISHU_ENDPOINTS.disconnect) { + value = await toPublicFeishuStatus(await controller.disconnect(), { encodeQr: cachedEncodeQr }); + } else if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot) { + if (typeof controller.reconnectBot !== "function") throw new Error("Multi-bot reconnect is unavailable"); + value = await toPublicFeishuStatus(await controller.reconnectBot(payload.botId), { encodeQr: cachedEncodeQr }); + } else if (endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) { + if (typeof controller.disconnectBot !== "function") throw new Error("Multi-bot disconnect is unavailable"); + value = await toPublicFeishuStatus(await controller.disconnectBot(payload.botId), { encodeQr: cachedEncodeQr }); + } else { + if (typeof controller.deleteBot !== "function") throw new Error("Multi-bot delete is unavailable"); + value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr }); + } + if (signal?.aborted) return cancelled2(); + return { ok: true, value }; + } catch { + return signal?.aborted ? cancelled2() : internalFailure2(); + } + }; +} +function installFeishuRpc(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( + FEISHU_RPC_CHANNEL, + createFeishuRpcHandler(controller, options), + { authority: "loopback" } + ); +} + +// plugin-src/host/channels/feishu/index.mjs +function controllerFrom(ctx, config) { + if (config?.controller) return config.controller; + if (typeof config?.createController === "function") return config.createController(); + if (typeof config?.createProvisioningManager === "function") { + return createProvisioningBackedController(config); + } + return void 0; +} +async function apply2(ctx, config = {}) { + const controller = controllerFrom(ctx, config); + if (controller) return installFeishuRpc(ctx, controller); + const production = await createProductionController2(ctx, config); + const disposeRpc = installFeishuRpc(ctx, production.controller); + ctx.effect(() => async () => { + await production.close(); + }, "dsh-feishu: close controller and live connection"); + return disposeRpc; +} + +// plugin-src/host/channels/weixin/production.mjs +import { unlink as unlink8 } from "node:fs/promises"; +import { homedir as homedir3 } from "node:os"; +import { join as join3, resolve as resolve3 } from "node:path"; + +// src/channels/weixin/config-store.mjs +import { createHash as createHash3 } from "node:crypto"; +import { mkdir as mkdir5, readFile as readFile5, rename as rename5, unlink as unlink6, writeFile as writeFile5 } from "node:fs/promises"; +import { dirname as dirname5 } from "node:path"; + +// src/channels/weixin/weixin-api.mjs +import { randomBytes, randomUUID as randomUUID8 } 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"; +var ILINK_APP_ID = "bot"; +var ILINK_CLIENT_VERSION = 2 << 16 | 4 << 8 | 6; +var DEFAULT_TIMEOUT_MS2 = 15e3; +var DEFAULT_LONG_POLL_TIMEOUT_MS = 35e3; +var LOGIN_STATUSES = /* @__PURE__ */ new Set([ + "wait", + "scaned", + "confirmed", + "expired", + "scaned_but_redirect", + "need_verifycode", + "verify_code_blocked", + "binded_redirect" +]); +var WeixinApiError = class extends Error { + constructor(code, message, options = {}) { + super(message, options); + this.name = "WeixinApiError"; + this.code = code; + this.status = options.status; + } +}; +function nonEmptyString5(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function isWeixinHost(hostname) { + const normalized = hostname.toLowerCase().replace(/\.$/, ""); + return normalized === "weixin.qq.com" || normalized.endsWith(".weixin.qq.com"); +} +function normalizeWeixinApiBaseUrl(value) { + let url; + try { + url = new URL(value); + } catch { + throw new WeixinApiError("invalid-base-url", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6548\u7684\u8FDE\u63A5\u5730\u5740\u3002"); + } + if (url.protocol !== "https:" || !isWeixinHost(url.hostname) || url.port !== "" && url.port !== "443") { + throw new WeixinApiError("untrusted-base-url", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u53D7\u4FE1\u4EFB\u7684\u8FDE\u63A5\u5730\u5740\u3002"); + } + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + if (!url.pathname.endsWith("/")) url.pathname += "/"; + return url.toString(); +} +function normalizeWeixinQrUrl(value) { + const text = nonEmptyString5(value); + if (!text) throw new WeixinApiError("invalid-qr", "\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u626B\u7801\u5730\u5740\u3002"); + let url; + try { + url = new URL(text); + } catch { + throw new WeixinApiError("invalid-qr", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6548\u7684\u626B\u7801\u5730\u5740\u3002"); + } + if (url.protocol !== "https:" || !isWeixinHost(url.hostname)) { + throw new WeixinApiError("untrusted-qr", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u53D7\u4FE1\u4EFB\u7684\u626B\u7801\u5730\u5740\u3002"); + } + return url.toString(); +} +function commonHeaders() { + return { + "iLink-App-Id": ILINK_APP_ID, + "iLink-App-ClientVersion": String(ILINK_CLIENT_VERSION) + }; +} +function authenticatedHeaders(token) { + const headers = { + ...commonHeaders(), + "content-type": "application/json", + AuthorizationType: "ilink_bot_token", + "X-WECHAT-UIN": Buffer.from(String(randomBytes(4).readUInt32BE(0)), "utf8").toString("base64") + }; + if (nonEmptyString5(token)) headers.Authorization = `Bearer ${token.trim()}`; + return headers; +} +function baseInfo() { + return { + channel_version: WEIXIN_PROTOCOL_VERSION, + bot_agent: "DeepSeekHarness/0.1.0" + }; +} +function abortError3(signal) { + if (signal?.reason instanceof Error) return signal.reason; + return new DOMException("The operation was aborted", "AbortError"); +} +async function requestJson2(fetchImpl, { + method, + baseUrl, + endpoint, + body, + token, + timeoutMs = DEFAULT_TIMEOUT_MS2, + signal, + authenticated = true +}) { + const trustedBase = normalizeWeixinApiBaseUrl(baseUrl); + const url = new URL(endpoint, trustedBase); + if (!isWeixinHost(url.hostname)) { + throw new WeixinApiError("untrusted-endpoint", "\u62D2\u7EDD\u8BBF\u95EE\u4E0D\u53D7\u4FE1\u4EFB\u7684\u5FAE\u4FE1\u670D\u52A1\u5730\u5740\u3002"); + } + const controller = new AbortController(); + let timedOut = false; + const onAbort = () => controller.abort(signal?.reason); + if (signal?.aborted) throw abortError3(signal); + signal?.addEventListener("abort", onAbort, { once: true }); + const timer = timeoutMs > 0 ? setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs) : null; + try { + const response = await fetchImpl(url, { + method, + headers: authenticated ? authenticatedHeaders(token) : commonHeaders(), + ...body === void 0 ? {} : { body: JSON.stringify(body) }, + signal: controller.signal + }); + if (!response.ok) { + throw new WeixinApiError( + "http-error", + `\u5FAE\u4FE1\u670D\u52A1\u8BF7\u6C42\u5931\u8D25\uFF08HTTP ${response.status}\uFF09\u3002`, + { status: response.status } + ); + } + try { + return await response.json(); + } catch (error) { + throw new WeixinApiError("invalid-response", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u89E3\u6790\u7684\u54CD\u5E94\u3002", { cause: error }); + } + } catch (error) { + if (signal?.aborted) throw abortError3(signal); + if (timedOut) { + throw new WeixinApiError("timeout", "\u5FAE\u4FE1\u670D\u52A1\u8BF7\u6C42\u8D85\u65F6\u3002", { cause: error }); + } + if (error instanceof WeixinApiError) throw error; + throw new WeixinApiError("network-error", "\u6682\u65F6\u65E0\u6CD5\u8BBF\u95EE\u5FAE\u4FE1\u670D\u52A1\u3002", { cause: error }); + } finally { + if (timer) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } +} +function validateLoginResponse(value) { + if (!value || typeof value !== "object" || !LOGIN_STATUSES.has(value.status)) { + throw new WeixinApiError("invalid-login-status", "\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u626B\u7801\u72B6\u6001\u3002"); + } + return value; +} +function createWeixinApi({ fetchImpl = fetch } = {}) { + if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function"); + 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, { + method: "POST", + baseUrl: WEIXIN_QR_BASE_URL, + endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, + body: { local_token_list: tokens }, + timeoutMs: 1e4, + signal + }); + const qrcode = nonEmptyString5(response?.qrcode); + if (!qrcode) throw new WeixinApiError("invalid-qr", "\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u4E8C\u7EF4\u7801\u4EE4\u724C\u3002"); + return { + qrcode, + qrcodeUrl: normalizeWeixinQrUrl(response.qrcode_img_content) + }; + }, + async pollLogin({ qrcode, baseUrl = WEIXIN_QR_BASE_URL, verifyCode, signal }) { + const qr = nonEmptyString5(qrcode); + 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, { + method: "GET", + baseUrl, + endpoint, + timeoutMs: DEFAULT_LONG_POLL_TIMEOUT_MS, + signal, + authenticated: false + }); + return validateLoginResponse(response); + }, + async getUpdates({ baseUrl, token, getUpdatesBuf = "", timeoutMs, signal }) { + try { + return await requestJson2(fetchImpl, { + method: "POST", + baseUrl, + endpoint: "ilink/bot/getupdates", + body: { get_updates_buf: getUpdatesBuf, base_info: baseInfo() }, + token, + timeoutMs: timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS, + signal + }); + } catch (error) { + if (error instanceof WeixinApiError && error.code === "timeout") { + return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf }; + } + throw error; + } + }, + async sendText({ baseUrl, token, toUserId, text, contextToken, runId, signal }) { + const recipient = nonEmptyString5(toUserId); + const content = nonEmptyString5(text); + if (!recipient || !content) throw new TypeError("toUserId and text are required"); + const response = await requestJson2(fetchImpl, { + method: "POST", + baseUrl, + endpoint: "ilink/bot/sendmessage", + token, + signal, + body: { + msg: { + from_user_id: "", + to_user_id: recipient, + client_id: `dsh-weixin-${randomUUID8()}`, + message_type: 2, + message_state: 2, + item_list: [{ type: 1, text_item: { text: content } }], + ...nonEmptyString5(contextToken) ? { context_token: contextToken.trim() } : {}, + ...nonEmptyString5(runId) ? { run_id: runId.trim() } : {} + }, + base_info: baseInfo() + } + }); + if (response?.ret !== void 0 && response.ret !== 0) { + throw new WeixinApiError("send-rejected", "\u5FAE\u4FE1\u670D\u52A1\u62D2\u7EDD\u4E86\u56DE\u590D\u6D88\u606F\u3002"); + } + return true; + }, + async notifyStart({ baseUrl, token, signal }) { + const response = await requestJson2(fetchImpl, { + method: "POST", + baseUrl, + endpoint: "ilink/bot/msg/notifystart", + token, + signal, + timeoutMs: 1e4, + body: { base_info: baseInfo() } + }); + if (response?.ret !== void 0 && response.ret !== 0) { + throw new WeixinApiError("start-rejected", "\u5FAE\u4FE1\u8D26\u53F7\u8FDE\u63A5\u542F\u52A8\u5931\u8D25\u3002"); + } + return response; + }, + async notifyStop({ baseUrl, token, signal }) { + return requestJson2(fetchImpl, { + method: "POST", + baseUrl, + endpoint: "ilink/bot/msg/notifystop", + token, + signal, + timeoutMs: 1e4, + body: { base_info: baseInfo() } + }); + } + }); +} +function extractWeixinText(message) { + for (const item of message?.item_list ?? []) { + if (item?.type === 1 && typeof item.text_item?.text === "string") { + const text = item.text_item.text.trim(); + if (text) return text; + } + if (item?.type === 3 && typeof item.voice_item?.text === "string") { + const text = item.voice_item.text.trim(); + if (text) return text; + } + } + return null; +} +function weixinMessageId(message) { + if (message?.message_id !== void 0 && message.message_id !== null) { + return String(message.message_id); + } + return nonEmptyString5(message?.client_id); +} +function splitWeixinText(text, maxChars = 4e3) { + if (text.length <= maxChars) return [text]; + const chunks = []; + let remaining = text; + while (remaining.length > maxChars) { + let splitAt = remaining.lastIndexOf("\n", maxChars); + if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n+/, ""); + } + if (remaining) chunks.push(remaining); + return chunks; +} + +// src/channels/weixin/config-store.mjs +var EMPTY_DOCUMENT2 = Object.freeze({ version: 1, accounts: Object.freeze([]) }); +function cleanString5(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function safeBotId2(value) { + const id = cleanString5(value); + return id && /^wx_[a-f0-9]{24}$/.test(id) ? id : null; +} +function safeTokenRef(value) { + const ref = cleanString5(value); + return ref && /^DSH_WEIXIN_BOT_TOKEN_[A-F0-9]{24}$/.test(ref) ? ref : null; +} +function deriveWeixinBotIdentity(accountId) { + const raw = cleanString5(accountId); + if (!raw) throw new TypeError("accountId is required"); + const digest2 = createHash3("sha256").update(raw).digest("hex").slice(0, 24); + return { + botId: `wx_${digest2}`, + tokenRef: `DSH_WEIXIN_BOT_TOKEN_${digest2.toUpperCase()}` + }; +} +function maskWeixinAccountId(accountId) { + const value = cleanString5(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 = cleanString5(value.accountId); + const ownerUserId = cleanString5(value.ownerUserId); + const botId = safeBotId2(value.botId); + const tokenRef = safeTokenRef(value.tokenRef); + if (!accountId || !ownerUserId || !botId || !tokenRef) return null; + const derived = deriveWeixinBotIdentity(accountId); + if (derived.botId !== botId || derived.tokenRef !== tokenRef) return null; + let baseUrl; + try { + baseUrl = normalizeWeixinApiBaseUrl(value.baseUrl); + } catch { + return null; + } + return Object.freeze({ + botId, + accountId, + tokenRef, + ownerUserId, + baseUrl, + createdAt: cleanString5(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: cleanString5(value.connectedAt) + }); +} +function normalizeDocument3(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; + const ids = /* @__PURE__ */ new Set(); + const accountIds = /* @__PURE__ */ new Set(); + const refs = /* @__PURE__ */ new Set(); + for (const account of accounts) { + if (ids.has(account.botId) || accountIds.has(account.accountId) || refs.has(account.tokenRef)) { + return null; + } + ids.add(account.botId); + accountIds.add(account.accountId); + refs.add(account.tokenRef); + } + return Object.freeze({ version: 1, accounts: Object.freeze(accounts) }); +} +var WeixinConfigStore = class { + #path; + #value = EMPTY_DOCUMENT2; + #writeQueue = Promise.resolve(); + constructor(path) { + this.#path = path; + } + async load() { + try { + const normalized = normalizeDocument3(JSON.parse(await readFile5(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_DOCUMENT2; + } + return this; + } + list() { + return structuredClone(this.#value.accounts); + } + get(botId) { + const account = this.#value.accounts.find((candidate) => candidate.botId === botId); + return account ? structuredClone(account) : null; + } + getByAccountId(accountId) { + const account = this.#value.accounts.find((candidate) => candidate.accountId === accountId); + return account ? structuredClone(account) : null; + } + async save(value) { + const normalized = normalizeAccount(value); + if (!normalized) throw new Error("Refusing to persist incomplete dsh-weixin account data"); + return this.#mutate((accounts) => { + const accountCollision = accounts.find( + (account) => account.accountId === normalized.accountId && account.botId !== normalized.botId + ); + const refCollision = accounts.find( + (account) => account.tokenRef === normalized.tokenRef && account.botId !== normalized.botId + ); + if (accountCollision || refCollision) throw new Error("Duplicate Weixin account identity"); + const index = accounts.findIndex((account) => account.botId === normalized.botId); + if (index === -1) accounts.push(normalized); + else accounts[index] = normalized; + return structuredClone(normalized); + }); + } + async remove(botId) { + if (!safeBotId2(botId)) throw new TypeError("Invalid Weixin bot id"); + return this.#mutate((accounts) => { + const index = accounts.findIndex((account) => account.botId === botId); + if (index === -1) return null; + const [removed] = accounts.splice(index, 1); + return structuredClone(removed); + }); + } + async clear() { + const operation = this.#writeQueue.then(async () => { + try { + await unlink6(this.#path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + this.#value = EMPTY_DOCUMENT2; + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + } + async #mutate(mutator) { + let result; + const operation = this.#writeQueue.then(async () => { + const accounts = [...this.#value.accounts]; + result = mutator(accounts); + const document = Object.freeze({ version: 1, accounts: Object.freeze(accounts) }); + await this.#write(document); + this.#value = document; + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + return result; + } + async #write(document) { + await mkdir5(dirname5(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.tmp`; + await writeFile5(temporary, `${JSON.stringify(document, null, 2)} +`, { + encoding: "utf8", + mode: 384 + }); + await rename5(temporary, this.#path); + } +}; + +// src/channels/weixin/harness-client.mjs +import { spawn as spawn3 } from "node:child_process"; +import { randomUUID as randomUUID9 } from "node:crypto"; +var sleep3 = (ms) => new Promise((resolve4) => setTimeout(resolve4, 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(); +} +var HarnessReplyTracker3 = class { + #promptRpcId; + #lastSeq; + #openTurn = null; + #targetTurn = null; + #stepText = /* @__PURE__ */ new Map(); + #latestText = ""; + #finished = false; + #reason = null; + constructor({ promptRpcId, afterSeq = -1 }) { + this.#promptRpcId = promptRpcId; + this.#lastSeq = afterSeq; + } + get finished() { + return this.#finished; + } + get answer() { + return this.#latestText.trim(); + } + get reason() { + return this.#reason; + } + consume(entries) { + let update = null; + const ordered = [...entries].map((entry) => entry?.event ?? entry).filter(Boolean).sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1)); + for (const event of ordered) { + const seq = event.seq ?? -1; + if (seq <= this.#lastSeq) continue; + this.#lastSeq = seq; + if (event.type === "turn/start") this.#openTurn = event.data?.turn ?? null; + if (event.type === "user/message" && event.data?.source?.rpcId === this.#promptRpcId) { + this.#targetTurn = this.#openTurn; + continue; + } + if (this.#targetTurn === null) continue; + if (event.type === "turn/end") { + if (event.data?.turn !== this.#targetTurn) continue; + this.#finished = true; + this.#reason = event.data?.reason ?? null; + this.#openTurn = null; + continue; + } + if (event.data?.turn !== this.#targetTurn) continue; + if (event.type === "assistant/chunk" && event.data?.chunk?.type === "text-delta") { + const step = event.data?.step ?? 0; + const index = event.data.chunk.index ?? 0; + const key = `${step}:${index}`; + this.#stepText.set(key, (this.#stepText.get(key) ?? "") + event.data.chunk.text); + const prefix = `${step}:`; + const text = [...this.#stepText.entries()].filter(([partKey]) => partKey.startsWith(prefix)).sort(([left], [right]) => Number(left.split(":")[1]) - Number(right.split(":")[1])).map(([, part]) => part).join("\n").trim(); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: "text", text }; + } + continue; + } + if (event.type === "assistant/message") { + const text = assistantMessageText2(event); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: "text", text }; + } + continue; + } + if (event.type === "tool/call") { + update = { type: "tool", name: event.data?.name ?? "\u5DE5\u5177" }; + } else if (event.type === "tool/result") { + update = { type: "status", text: "\u6B63\u5728\u6574\u7406\u7ED3\u679C\u2026" }; + } + } + return update; + } +}; +var HarnessRpcError3 = class extends Error { + constructor(method, error) { + super(`${method}: ${error?.message ?? "unknown Harness RPC error"}`); + this.name = "HarnessRpcError"; + this.method = method; + this.code = error?.code ?? "internal"; + this.details = error?.details ?? {}; + } +}; +var HarnessClient3 = class { + #baseUrl; + #workspace; + #agentPreset; + #autostart; + #dshBin; + #managedProcess = null; + constructor({ baseUrl, workspace, agentPreset = "standard", autostart = false, dshBin = "dsh" }) { + this.#baseUrl = new URL(baseUrl); + this.#workspace = workspace; + this.#agentPreset = agentPreset; + this.#autostart = autostart; + this.#dshBin = dshBin; + } + async rpc(method, payload = {}, timeoutMs = 3e4, options = {}) { + const rpcId = options.rpcId ?? `weixin-${randomUUID9()}`; + const response = await fetch(new URL(`/api/${method}`, this.#baseUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "client-request", rpcId, method, payload }), + signal: AbortSignal.timeout(timeoutMs) + }); + if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`); + const body = await response.json(); + if (body?.type !== "server-response" || body?.rpcId !== rpcId) { + throw new Error(`Harness returned an invalid response for ${method}`); + } + if (!body.result?.ok) throw new HarnessRpcError3(method, body.result?.error); + return body.result.value; + } + async health() { + await this.rpc("host.describe", {}, 5e3); + return true; + } + async ensureRunning() { + try { + return await this.health(); + } catch (firstError) { + if (!this.#autostart) throw firstError; + } + if (!this.#managedProcess || this.#managedProcess.exitCode !== null) { + const port = this.#baseUrl.port || (this.#baseUrl.protocol === "https:" ? "443" : "80"); + this.#managedProcess = spawn3(this.#dshBin, [ + "web", + "--host", + this.#baseUrl.hostname, + "--port", + port + ], { + cwd: this.#workspace, + env: process.env, + stdio: ["ignore", "inherit", "inherit"] + }); + this.#managedProcess.on("error", (error) => { + console.error("[dsh-weixin] failed to start Harness:", error.message); + }); + } + const deadline = Date.now() + 6e4; + let lastError; + while (Date.now() < deadline) { + await sleep3(1e3); + try { + return await this.health(); + } catch (error) { + lastError = error; + } + } + throw new Error(`Harness did not become ready: ${lastError?.message ?? "timeout"}`); + } + async workspaceId() { + const { items } = await this.rpc("workspace.list", {}); + const existing = items.find((item) => item.path === this.#workspace); + if (existing) return existing.workspaceId; + const created = await this.rpc("workspace.create", { path: this.#workspace }); + return created.workspace.workspaceId; + } + async createSession() { + await this.ensureRunning(); + const workspaceId = await this.workspaceId(); + const created = await this.rpc("session.create", { + workspaceId, + agentPreset: this.#agentPreset + }); + return created.sessionId; + } + async sessionExists(sessionId) { + try { + await this.rpc("session.history", { sessionId, maxMessages: 1 }); + return true; + } catch (error) { + if (error instanceof HarnessRpcError3 && error.code === "session-not-found") return false; + throw error; + } + } + async ask(sessionId, text, options = {}) { + if (typeof options === "number") options = { timeoutMs: options }; + const timeoutMs = options.timeoutMs ?? 6e5; + const onUpdate = typeof options.onUpdate === "function" ? options.onUpdate : null; + await this.ensureRunning(); + const before = await this.rpc("session.history", { sessionId, maxMessages: 1 }); + const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1)); + const promptRpcId = `weixin-${randomUUID9()}`; + const tracker = new HarnessReplyTracker3({ promptRpcId, afterSeq: baselineSeq }); + await this.rpc("session.prompt", { + sessionId, + mode: "queue", + content: [{ type: "text", text }], + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone + }, 3e4, { rpcId: promptRpcId }); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep3(300); + const history = await this.rpc("session.history", { sessionId, maxMessages: 50 }); + const update = tracker.consume(history.events ?? []); + if (update && onUpdate) { + try { + await onUpdate(update); + } catch (error) { + console.warn("[dsh-weixin] ignored a progress update failure:", error.message); + } + } + if (!tracker.finished) continue; + if (tracker.answer) return tracker.answer; + throw new Error( + `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ""}` + ); + } + throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1e3)} seconds`); + } + stopManagedProcess() { + if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill("SIGTERM"); + } +}; + +// src/channels/weixin/state-store.mjs +import { mkdir as mkdir6, readFile as readFile6, rename as rename6, unlink as unlink7, writeFile as writeFile6 } from "node:fs/promises"; +import { dirname as dirname6 } from "node:path"; +var EMPTY_STATE3 = Object.freeze({ + version: 1, + sessions: {}, + seenMessageIds: [], + getUpdatesBuf: "" +}); +function normalizeState2(value) { + if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE3); + 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) : [], + getUpdatesBuf: typeof value.getUpdatesBuf === "string" ? value.getUpdatesBuf : "" + }; +} +var WeixinStateStore = class { + #path; + #state = structuredClone(EMPTY_STATE3); + #writeQueue = Promise.resolve(); + constructor(path) { + this.#path = path; + } + async load() { + try { + this.#state = normalizeState2(JSON.parse(await readFile6(this.#path, "utf8"))); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + this.#state = structuredClone(EMPTY_STATE3); + 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(); + } + getUpdatesBuf() { + return this.#state.getUpdatesBuf; + } + async setGetUpdatesBuf(value) { + if (typeof value !== "string" || value === this.#state.getUpdatesBuf) return; + this.#state.getUpdatesBuf = value; + await this.#persist(); + } + snapshot() { + return structuredClone(this.#state); + } + async remove() { + try { + await unlink7(this.#path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + this.#state = structuredClone(EMPTY_STATE3); + } + async #persist() { + const snapshot = `${JSON.stringify(this.#state, null, 2)} +`; + const operation = this.#writeQueue.then(async () => { + await mkdir6(dirname6(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.tmp`; + await writeFile6(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename6(temporary, this.#path); + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + } +}; + +// src/channels/weixin/weixin-controller.mjs +import { randomUUID as randomUUID10 } from "node:crypto"; +var ACTIVE_ATTEMPT_STATES2 = /* @__PURE__ */ new Set([ + "starting", + "pending", + "scanned", + "needs_verification", + "connecting" +]); +var TERMINAL_ATTEMPT_STATES2 = /* @__PURE__ */ new Set(["connected", "expired", "failed", "cancelled"]); +var QR_TTL_MS = 5 * 6e4; +function cleanString6(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 = cleanString6(value); + if (!raw) return normalizeWeixinApiBaseUrl(fallback); + return normalizeWeixinApiBaseUrl(raw.includes("://") ? raw : `https://${raw}`); +} +function publicAttempt2(record) { + if (!record) return null; + return { + attemptId: record.id, + status: record.state, + ...record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}, + ...record.expiresAt ? { expiresAt: record.expiresAt } : {}, + pollIntervalMs: 1e3, + ...record.state === "needs_verification" ? { verificationRequired: true } : {}, + ...record.botId ? { botId: record.botId } : {}, + ...record.alreadyConnected ? { alreadyConnected: true } : {}, + ...record.error ? { error: structuredClone(record.error) } : {} + }; +} +function safeAccountError(code, message) { + return Object.freeze({ code, message }); +} +var WeixinController = class { + #api; + #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({ + api, + credentials, + configStore, + createRuntime, + deleteState = async () => { + }, + logger = console + }) { + if (!api || typeof api.beginLogin !== "function" || typeof api.pollLogin !== "function") { + throw new TypeError("WeixinController requires a Weixin API client"); + } + if (!credentials || typeof credentials.resolve !== "function" || typeof credentials.set !== "function" || typeof credentials.unset !== "function") { + throw new TypeError("WeixinController requires the DSH credential provider"); + } + if (!configStore || typeof configStore.list !== "function" || typeof configStore.save !== "function" || typeof configStore.remove !== "function") { + throw new TypeError("WeixinController requires a config store"); + } + if (typeof createRuntime !== "function") throw new TypeError("createRuntime is required"); + this.#api = api; + 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()) { + const current = this.#runtimes.get(config.botId); + if (current?.status?.ready === true) continue; + await this.#withBotTransition(config.botId, async () => { + const latest = this.#configStore.get(config.botId); + if (!latest || this.#closed) return; + try { + const token = await this.#resolveToken(latest.tokenRef); + if (!token) { + this.#errors.set( + latest.botId, + safeAccountError("missing-token", "\u767B\u5F55\u51ED\u636E\u7F3A\u5931\uFF0C\u8BF7\u79FB\u9664\u8D26\u53F7\u540E\u91CD\u65B0\u626B\u7801\u3002") + ); + return; + } + await this.#startRuntime(latest, token); + this.#errors.delete(latest.botId); + } catch (error) { + this.#errors.set( + latest.botId, + safeAccountError("connection-failed", "\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5\u3002") + ); + this.#logger.warn?.(`[dsh-weixin] account ${latest.botId} failed to initialize:`, error); + } finally { + this.#touch(); + } + }); + } + return this.status(); + } + async startProvisioning() { + if (this.#closed) throw new Error("dsh-weixin controller is closed"); + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + const record = { + id: randomUUID10(), + state: "starting", + createdAt: Date.now(), + expiresAt: Date.now() + QR_TTL_MS, + controller: new AbortController(), + pendingVerifyCode: null, + verifyResolve: null, + currentBaseUrl: WEIXIN_QR_BASE_URL, + error: null, + botId: null, + task: null + }; + this.#attempts.set(record.id, record); + this.#activeAttemptId = record.id; + this.#touch(); + try { + const localTokens = (await Promise.all( + this.#configStore.list().slice(-10).map(async (config) => this.#resolveToken(config.tokenRef)) + )).filter(Boolean); + const login = await this.#api.beginLogin({ + localTokens, + signal: record.controller.signal + }); + this.#assertAttemptActive(record); + record.qrcode = login.qrcode; + record.verificationUrl = login.qrcodeUrl; + record.state = "pending"; + record.expiresAt = Date.now() + QR_TTL_MS; + this.#touch(); + record.task = this.#runProvisioning(record); + return publicAttempt2(record); + } catch (error) { + if (record.controller.signal.aborted) { + record.state = "cancelled"; + record.error = safeAccountError("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); + } else { + record.state = "failed"; + record.error = safeAccountError( + error instanceof WeixinApiError ? error.code : "qr-start-failed", + error instanceof WeixinApiError ? error.message : "\u65E0\u6CD5\u751F\u6210\u5FAE\u4FE1\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" + ); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + throw error; + } + } + registrationStatus(attemptId) { + return publicAttempt2(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 = cleanString6(verifyCode); + if (!code || !/^\d{4,8}$/.test(code)) { + throw new TypeError("Verification code must contain 4 to 8 digits"); + } + record.pendingVerifyCode = code; + record.state = "scanned"; + record.verifyResolve?.(); + record.verifyResolve = null; + this.#touch(); + return publicAttempt2(record); + } + async cancelProvisioning(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record) return null; + if (!TERMINAL_ATTEMPT_STATES2.has(record.state)) { + record.controller.abort(); + record.verifyResolve?.(); + record.verifyResolve = null; + await record.task?.catch(() => void 0); + if (!TERMINAL_ATTEMPT_STATES2.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 publicAttempt2(record); + } + async reconnectBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error("Unknown Weixin account"); + await this.#withBotTransition(botId, async () => { + const token = await this.#resolveToken(config.tokenRef); + if (!token) throw new Error("The Weixin token is missing"); + try { + await this.#startRuntime(config, token); + this.#errors.delete(botId); + } catch (error) { + this.#errors.set(botId, safeAccountError("connection-failed", "\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 Weixin account"); + await this.#withBotTransition(botId, async () => { + const previousToken = await this.#credentials.resolve(config.tokenRef).catch(() => void 0); + await this.#stopRuntime(botId); + try { + await this.#credentials.unset(config.tokenRef); + await this.#configStore.remove(botId); + } catch (error) { + if (previousToken?.value) { + await this.#credentials.set(config.tokenRef, previousToken.value).catch(() => void 0); + await this.#startRuntime(config, previousToken.value).catch(() => void 0); + } + throw new Error("Unable to remove the Weixin account safely.", { cause: error }); + } + try { + await this.#deleteState({ botId, config }); + } catch (error) { + this.#logger.warn?.(`[dsh-weixin] account ${botId} state cleanup failed:`, error); + } + this.#errors.delete(botId); + this.#touch(); + }); + return this.status(); + } + status() { + const accounts = this.#configStore.list().map((config) => { + const runtimeStatus2 = this.#runtimes.get(config.botId)?.status ?? null; + const connected = runtimeStatus2?.ready === true && runtimeStatus2.weixinConnectionState === "connected" && runtimeStatus2.harnessReachable === true; + const state = connected ? "connected" : runtimeStatus2?.weixinConnectionState === "connecting" ? "connecting" : this.#errors.has(config.botId) || runtimeStatus2?.weixinConnectionState === "failed" ? "error" : "offline"; + const error = this.#errors.get(config.botId) ?? (state === "error" ? safeAccountError("connection-failed", "\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5\u3002") : null); + return { + botId: config.botId, + state, + connected, + configured: true, + bot: { + name: "\u5FAE\u4FE1\u673A\u5668\u4EBA", + accountIdMasked: maskWeixinAccountId(config.accountId) + }, + health: { + status: connected ? "healthy" : state === "error" ? "error" : "offline", + summary: connected ? "\u5FAE\u4FE1\u6D88\u606F\u957F\u8F6E\u8BE2\u8FD0\u884C\u6B63\u5E38" : state === "error" ? "\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5" : "\u5FAE\u4FE1\u8FDE\u63A5\u5F53\u524D\u79BB\u7EBF", + lastCheckedAt: runtimeStatus2?.lastCheckedAt ?? null + }, + stats: { + messagesReceived: runtimeStatus2?.messagesReceived ?? 0, + messagesReplied: runtimeStatus2?.messagesReplied ?? 0 + }, + error: error ? structuredClone(error) : null + }; + }); + const connectedCount = accounts.filter((account) => account.connected).length; + const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null; + return { + schemaVersion: 1, + revision: this.#revision, + state: active && ACTIVE_ATTEMPT_STATES2.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_STATES2.has(active.state) ? { provisioning: publicAttempt2(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 #runProvisioning(record) { + try { + while (!record.controller.signal.aborted && Date.now() < record.expiresAt) { + if (record.state === "needs_verification" && !record.pendingVerifyCode) { + await new Promise((resolve4) => { + record.verifyResolve = resolve4; + if (record.controller.signal.aborted) resolve4(); + }); + record.verifyResolve = null; + this.#assertAttemptActive(record); + } + const response = await this.#api.pollLogin({ + qrcode: record.qrcode, + baseUrl: record.currentBaseUrl, + verifyCode: record.pendingVerifyCode, + signal: record.controller.signal + }); + this.#assertAttemptActive(record); + if (response.status === "wait") { + record.state = "pending"; + } else if (response.status === "scaned") { + record.pendingVerifyCode = null; + record.state = "scanned"; + } else if (response.status === "need_verifycode") { + record.pendingVerifyCode = null; + record.state = "needs_verification"; + } else if (response.status === "verify_code_blocked") { + record.state = "failed"; + record.error = safeAccountError("verification-blocked", "\u914D\u5BF9\u7801\u591A\u6B21\u9519\u8BEF\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u3002"); + break; + } else if (response.status === "expired") { + record.state = "expired"; + record.error = safeAccountError("expired", "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); + break; + } else if (response.status === "scaned_but_redirect") { + record.currentBaseUrl = apiBaseFromServer(response.redirect_host, record.currentBaseUrl); + record.state = "scanned"; + } else if (response.status === "binded_redirect") { + const existing = this.#configStore.list().find( + (config) => this.#runtimes.get(config.botId)?.status?.ready === true + ) ?? this.#configStore.list()[0]; + if (!existing) { + record.state = "failed"; + record.error = safeAccountError("already-bound", "\u8BE5\u5FAE\u4FE1\u8D26\u53F7\u5DF2\u7ED1\u5B9A\uFF0C\u4F46\u672C\u673A\u6CA1\u6709\u53EF\u6062\u590D\u7684\u51ED\u636E\u3002"); + } else { + record.state = "connected"; + record.botId = existing.botId; + record.alreadyConnected = true; + } + break; + } else if (response.status === "confirmed") { + const token = cleanString6(response.bot_token); + const accountId = cleanString6(response.ilink_bot_id); + const ownerUserId = cleanString6(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"); + } + record.state = "connecting"; + this.#touch(); + const baseUrl = apiBaseFromServer(response.baseurl, record.currentBaseUrl); + record.botId = await this.#activateAccount(record, { + token, + accountId, + ownerUserId, + baseUrl + }); + record.state = "connected"; + record.error = null; + break; + } + this.#touch(); + } + if (!record.controller.signal.aborted && Date.now() >= record.expiresAt && !TERMINAL_ATTEMPT_STATES2.has(record.state)) { + record.state = "expired"; + record.error = safeAccountError("expired", "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); + } + } catch (error) { + if (record.controller.signal.aborted || error?.name === "AbortError") { + record.state = "cancelled"; + record.error = safeAccountError("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); + } else { + record.state = "failed"; + record.error = safeAccountError( + error instanceof WeixinApiError ? error.code : "activation-failed", + error instanceof WeixinApiError ? error.message : "\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u4FDD\u5B58\u51ED\u636E\u6216\u542F\u52A8\u6D88\u606F\u8FDE\u63A5\u3002" + ); + this.#logger.error?.("[dsh-weixin] provisioning failed:", error); + } + } finally { + record.pendingVerifyCode = null; + record.verifyResolve?.(); + record.verifyResolve = null; + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + this.#pruneAttempts(); + } + } + async #activateAccount(record, { token, accountId, ownerUserId, baseUrl }) { + const identity = deriveWeixinBotIdentity(accountId); + const previousConfig = this.#configStore.getByAccountId(accountId); + const config = { + botId: identity.botId, + accountId, + tokenRef: identity.tokenRef, + ownerUserId, + baseUrl, + createdAt: previousConfig?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: (/* @__PURE__ */ new Date()).toISOString() + }; + const previousToken = await this.#credentials.resolve(identity.tokenRef).catch(() => void 0); + return this.#withBotTransition(identity.botId, async () => { + await this.#credentials.set(identity.tokenRef, token); + try { + this.#assertAttemptActive(record); + await this.#configStore.save(config); + this.#assertAttemptActive(record); + await this.#startRuntime(config, token); + this.#assertAttemptActive(record); + this.#errors.delete(identity.botId); + this.#touch(); + return identity.botId; + } catch (error) { + await this.#stopRuntime(identity.botId); + if (previousConfig) await this.#configStore.save(previousConfig).catch(() => void 0); + else if (this.#configStore.get(identity.botId)) { + await this.#configStore.remove(identity.botId).catch(() => void 0); + } + await this.#restoreCredential(identity.tokenRef, previousToken); + if (previousConfig && previousToken?.value) { + await this.#startRuntime(previousConfig, previousToken.value).catch(() => void 0); + } + throw error; + } + }); + } + async #startRuntime(config, token) { + await this.#stopRuntime(config.botId); + const runtime = await this.#createRuntime({ botId: config.botId, config, token }); + if (!runtime || typeof runtime.start !== "function" || typeof runtime.stop !== "function") { + throw new TypeError("createRuntime returned an invalid Weixin runtime"); + } + try { + await runtime.start(); + this.#runtimes.set(config.botId, runtime); + } catch (error) { + await runtime.stop().catch(() => void 0); + throw error; + } + } + async #stopRuntime(botId) { + const runtime = this.#runtimes.get(botId); + this.#runtimes.delete(botId); + await runtime?.stop().catch((error) => { + this.#logger.warn?.(`[dsh-weixin] account ${botId} failed to stop cleanly:`, error); + }); + } + async #resolveToken(ref) { + const result = await this.#credentials.resolve(ref).catch(() => void 0); + return cleanString6(result?.value); + } + async #restoreCredential(ref, previous) { + try { + if (previous?.value) await this.#credentials.set(ref, previous.value); + else await this.#credentials.unset(ref); + } catch (error) { + this.#logger.error?.(`[dsh-weixin] failed to restore credential ${ref}:`, error); + } + } + #assertAttemptActive(record) { + if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) throw abortError4(); + } + #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; + } + #pruneAttempts() { + for (const [id, record] of this.#attempts) { + if (id !== this.#activeAttemptId && TERMINAL_ATTEMPT_STATES2.has(record.state) && this.#attempts.size > 16) { + this.#attempts.delete(id); + } + } + } + #touch() { + this.#revision += 1; + } +}; + +// src/channels/weixin/weixin-bridge.mjs +var HELP_TEXT3 = [ + "\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", + "/new \u5F00\u542F\u4E00\u4E2A\u5168\u65B0\u4F1A\u8BDD", + "/status \u68C0\u67E5\u8FDE\u63A5\u72B6\u6001", + "/help \u663E\u793A\u672C\u5E2E\u52A9" +].join("\n"); +function conversationKey3(userId) { + return `p2p:${userId}`; +} +function createWeixinBridgeStatus() { + return { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null + }; +} +var WeixinHarnessBridge = class { + #api; + #baseUrl; + #token; + #ownerUserId; + #harness; + #state; + #status; + #logger; + #replyTimeoutMs; + #maxMessageChars; + #queues = /* @__PURE__ */ new Map(); + constructor({ + api, + baseUrl, + token, + ownerUserId, + harness, + state, + status = createWeixinBridgeStatus(), + logger = console, + replyTimeoutMs = 6e5, + maxMessageChars = 4e3 + }) { + if (!api || typeof api.sendText !== "function") throw new TypeError("Weixin API is required"); + if (!baseUrl || !token || !ownerUserId) throw new TypeError("Weixin account credentials are required"); + if (!harness || !state) throw new TypeError("Harness client and state store are required"); + this.#api = api; + this.#baseUrl = baseUrl; + this.#token = token; + this.#ownerUserId = ownerUserId; + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#maxMessageChars = maxMessageChars; + } + get status() { + return structuredClone(this.#status); + } + accept(message) { + const sender = typeof message?.from_user_id === "string" ? message.from_user_id : ""; + const previous = this.#queues.get(sender) ?? Promise.resolve(); + const current = previous.catch(() => void 0).then(() => this.#process(message)).finally(() => { + if (this.#queues.get(sender) === current) this.#queues.delete(sender); + }); + this.#queues.set(sender, current); + return current; + } + async waitForIdle() { + await Promise.allSettled([...this.#queues.values()]); + } + async #process(message) { + if (message?.message_type === 2) return; + const messageId = weixinMessageId(message); + const sender = typeof message?.from_user_id === "string" ? message.from_user_id : ""; + if (!messageId || !sender) return; + if (this.#state.hasSeen(messageId)) return; + this.#status.messagesReceived += 1; + this.#status.lastMessageAt = (/* @__PURE__ */ new Date()).toISOString(); + if (sender !== this.#ownerUserId) { + this.#status.messagesRejected += 1; + this.#status.lastRejectedAt = (/* @__PURE__ */ new Date()).toISOString(); + return; + } + const contextToken = typeof message.context_token === "string" ? message.context_token : void 0; + const runId = typeof message.run_id === "string" ? message.run_id : void 0; + const text = extractWeixinText(message); + try { + if (!text) { + await this.#send(sender, "\u76EE\u524D\u4EC5\u652F\u6301\u6587\u5B57\u6D88\u606F\uFF0C\u4EE5\u53CA\u5FAE\u4FE1\u5DF2\u8F6C\u6210\u6587\u5B57\u7684\u8BED\u97F3\u6D88\u606F\u3002", contextToken, runId); + await this.#state.markSeen(messageId); + return; + } + const command = text.trim().toLowerCase(); + if (command === "/help") { + await this.#send(sender, HELP_TEXT3, contextToken, runId); + await this.#state.markSeen(messageId); + return; + } + if (command === "/status") { + await this.#harness.ensureRunning(); + await this.#send(sender, "\u5FAE\u4FE1\u4E0E DeepSeek Harness \u8FDE\u63A5\u6B63\u5E38\u3002", contextToken, runId); + await this.#state.markSeen(messageId); + return; + } + if (command === "/new") { + await this.#state.clearSession(conversationKey3(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 = conversationKey3(sender); + let sessionId = this.#state.sessionFor(key); + if (!sessionId || !await this.#harness.sessionExists(sessionId)) { + sessionId = await this.#harness.createSession(); + await this.#state.setSession(key, sessionId); + } + const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs }); + await this.#send(sender, answer, contextToken, runId); + 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-weixin] failed to process an inbound message:", error); + try { + await this.#send(sender, "\u6D88\u606F\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002", contextToken, runId); + await this.#state.markSeen(messageId); + } catch (sendError) { + this.#logger.error?.("[dsh-weixin] failed to send the safe error reply:", sendError); + } + } + } + async #send(toUserId, text, contextToken, runId) { + for (const chunk of splitWeixinText(text, this.#maxMessageChars)) { + await this.#api.sendText({ + baseUrl: this.#baseUrl, + token: this.#token, + toUserId, + text: chunk, + contextToken, + runId + }); + } + } +}; + +// src/channels/weixin/weixin-runtime.mjs +function delay(ms, signal) { + return new Promise((resolve4, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + return; + } + const finish = () => { + signal?.removeEventListener("abort", onAbort); + resolve4(); + }; + const timer = setTimeout(finish, ms); + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} +function createWeixinRuntimeStatus() { + return { + startedAt: null, + ready: false, + weixinConnectionState: "idle", + harnessReachable: false, + lastCheckedAt: null, + lastError: null, + ...createWeixinBridgeStatus() + }; +} +var WeixinRuntime = class { + #api; + #config; + #token; + #harness; + #state; + #logger; + #replyTimeoutMs; + #maxMessageChars; + #status = createWeixinRuntimeStatus(); + #bridge = null; + #abortController = null; + #monitor = null; + #starting = null; + constructor({ + api, + config, + token, + harness, + state, + logger = console, + replyTimeoutMs = 6e5, + maxMessageChars = 4e3 + }) { + if (!api || !config || !token || !harness || !state) { + throw new TypeError("WeixinRuntime requires API, account, token, Harness, and state"); + } + this.#api = api; + this.#config = config; + this.#token = token; + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#maxMessageChars = maxMessageChars; + } + get status() { + return structuredClone(this.#status); + } + async start() { + if (this.#status.ready && this.#monitor) return this.status; + if (this.#starting) return this.#starting; + this.#starting = this.#start().finally(() => { + this.#starting = null; + }); + return this.#starting; + } + async #start() { + await this.stop(); + this.#status.startedAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.weixinConnectionState = "connecting"; + this.#status.lastError = null; + try { + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + await this.#api.notifyStart({ + baseUrl: this.#config.baseUrl, + token: this.#token + }); + this.#bridge = new WeixinHarnessBridge({ + api: this.#api, + baseUrl: this.#config.baseUrl, + token: this.#token, + ownerUserId: this.#config.ownerUserId, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs, + maxMessageChars: this.#maxMessageChars + }); + this.#abortController = new AbortController(); + this.#status.ready = true; + this.#status.weixinConnectionState = "connected"; + this.#status.lastCheckedAt = Date.now(); + const signal = this.#abortController.signal; + this.#monitor = this.#runMonitor(signal).catch((error) => { + if (signal.aborted) return; + this.#status.ready = false; + this.#status.weixinConnectionState = "failed"; + this.#status.lastError = error?.message ?? String(error); + this.#logger.error?.(`[dsh-weixin] account ${this.#config.botId} monitor stopped:`, error); + }); + return this.status; + } catch (error) { + this.#status.ready = false; + this.#status.weixinConnectionState = "failed"; + this.#status.lastError = error?.message ?? String(error); + throw error; + } + } + async #runMonitor(signal) { + let consecutiveFailures = 0; + while (!signal.aborted) { + try { + const response = await this.#api.getUpdates({ + baseUrl: this.#config.baseUrl, + token: this.#token, + getUpdatesBuf: this.#state.getUpdatesBuf(), + signal + }); + if (signal.aborted) return; + const rejected = response?.ret !== void 0 && response.ret !== 0 || response?.errcode !== void 0 && response.errcode !== 0; + if (rejected) { + const code = response.errcode ?? response.ret; + throw new WeixinApiError( + code === -14 ? "stale-token" : "updates-rejected", + code === -14 ? "\u5FAE\u4FE1\u767B\u5F55\u51ED\u636E\u5DF2\u5931\u6548\uFF0C\u8BF7\u79FB\u9664\u8D26\u53F7\u540E\u91CD\u65B0\u626B\u7801\u3002" : "\u5FAE\u4FE1\u6D88\u606F\u540C\u6B65\u8BF7\u6C42\u88AB\u62D2\u7EDD\u3002" + ); + } + consecutiveFailures = 0; + this.#status.ready = true; + this.#status.weixinConnectionState = "connected"; + this.#status.lastCheckedAt = Date.now(); + this.#status.lastError = null; + for (const message of response?.msgs ?? []) { + await this.#bridge.accept(message); + } + if (typeof response?.get_updates_buf === "string" && response.get_updates_buf) { + await this.#state.setGetUpdatesBuf(response.get_updates_buf); + } + } catch (error) { + if (signal.aborted) return; + consecutiveFailures += 1; + this.#status.lastError = error?.message ?? String(error); + this.#logger.warn?.( + `[dsh-weixin] account ${this.#config.botId} poll failed (${consecutiveFailures}/3):`, + error + ); + if (error instanceof WeixinApiError && error.code === "stale-token") throw error; + if (consecutiveFailures >= 3) throw error; + await delay(Math.min(2e3 * 2 ** (consecutiveFailures - 1), 1e4), signal); + } + } + } + async stop() { + const monitor = this.#monitor; + const bridge = this.#bridge; + const wasStarted = Boolean(this.#abortController || monitor || this.#status.ready); + this.#abortController?.abort(); + this.#abortController = null; + this.#monitor = null; + await monitor?.catch(() => void 0); + await bridge?.waitForIdle(); + this.#bridge = null; + if (wasStarted) { + try { + await this.#api.notifyStop({ + baseUrl: this.#config.baseUrl, + token: this.#token, + signal: AbortSignal.timeout(1e4) + }); + } catch (error) { + this.#logger.warn?.(`[dsh-weixin] account ${this.#config.botId} stop notification failed:`, error); + } + } + this.#status.ready = false; + this.#status.weixinConnectionState = "idle"; + return this.status; + } +}; + +// plugin-src/host/channels/weixin/connection-supervisor.mjs +var DEFAULT_RETRY_DELAYS_MS3 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); +function retryDelays2(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS3]; + const valid = value.filter((delay2) => Number.isFinite(delay2) && delay2 >= 0); + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS3]; +} +var ConnectionSupervisor3 = 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 = retryDelays2(retryDelaysMs); + this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; + this.#setTimeout = setTimeoutImpl; + this.#clearTimeout = clearTimeoutImpl; + this.#ready = new Promise((resolve4) => { + this.#resolveReady = resolve4; + }); + } + 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 delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-weixin] ${connected}/${configured} accounts connected; retrying in ${delayMs}ms` + ); + this.#schedule(delayMs); + return; + } + this.#retryIndex = 0; + this.#schedule(this.#healthyIntervalMs); + } catch (error) { + if (this.#closed) return; + const delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.(`[dsh-weixin] connection reconciliation failed; retrying in ${delayMs}ms`, error); + this.#schedule(delayMs); + } + } +}; +function createConnectionSupervisor3(options) { + return new ConnectionSupervisor3(options); +} + +// plugin-src/host/channels/weixin/production.mjs +function harnessOrigin3(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-weixin requires an initialized DSH webServer port"); + } + return new URL(`http://127.0.0.1:${port}`); +} +function pluginPaths3(config) { + const dshHome = resolve3(config.dshHome ?? process.env.DSH_HOME ?? join3(homedir3(), ".dsh")); + const root = resolve3(config.dataDir ?? join3(dshHome, "integrations", "dsh-weixin")); + return { + root, + config: resolve3(config.configPath ?? join3(root, "config.json")), + accounts: resolve3(config.accountsDir ?? join3(root, "accounts")) + }; +} +async function createProductionController3(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; + const StateStore2 = internals.StateStore ?? WeixinStateStore; + const Harness = internals.HarnessClient ?? HarnessClient3; + const Controller = internals.Controller ?? WeixinController; + const Runtime = internals.Runtime ?? WeixinRuntime; + const api = internals.api ?? createWeixinApi(); + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor3; + const logger = typeof ctx.logger === "function" ? ctx.logger("dsh-weixin") : ctx.logger ?? console; + const paths = pluginPaths3(config); + const configStore = await new ConfigStore(paths.config).load(); + const stateStores = /* @__PURE__ */ new Map(); + const statePath = (botId) => resolve3(paths.accounts, 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: harnessOrigin3(ctx.webServer, config.harnessBaseUrl), + workspace: resolve3(config.workspace ?? process.cwd()), + agentPreset: config.agentPreset ?? "standard", + autostart: false, + dshBin: config.dshBin ?? "dsh" + }); + const controller = new Controller({ + api, + credentials: ctx.credentials, + configStore, + logger, + createRuntime: async ({ botId, config: accountConfig, token }) => { + const state = await stateFor(botId); + return new Runtime({ + api, + config: accountConfig, + token, + harness, + state, + replyTimeoutMs: config.replyTimeoutMs ?? 6e5, + maxMessageChars: config.maxMessageChars ?? 4e3, + 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") { + await state.remove(); + return; + } + try { + await unlink8(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/weixin/rpc.mjs +import QRCode3 from "qrcode"; +var WEIXIN_RPC_CHANNEL = "/weixin"; +var WEIXIN_ENDPOINTS = Object.freeze({ + status: "connection.status", + beginProvisioning: "provision.begin", + pollProvisioning: "provision.poll", + submitVerification: "provision.verify", + cancelProvisioning: "provision.cancel", + reconnectBot: "bot.reconnect", + deleteBot: "bot.delete" +}); +var WEIXIN_RPC_ENDPOINTS = Object.freeze(Object.values(WEIXIN_ENDPOINTS)); +function isRecord2(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function exactKeys2(value, allowed) { + return isRecord2(value) && Object.keys(value).every((key) => allowed.includes(key)); +} +function validId2(value) { + return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); +} +function payloadFailure2(endpoint, payload) { + if (!isRecord2(payload)) return "Payload must be an object."; + if (endpoint === WEIXIN_ENDPOINTS.status) { + return exactKeys2(payload, []) ? null : "connection.status does not accept fields."; + } + if (endpoint === WEIXIN_ENDPOINTS.beginProvisioning) { + return exactKeys2(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 exactKeys2(payload, ["attemptId"]) && validId2(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; + } + if (endpoint === WEIXIN_ENDPOINTS.submitVerification) { + return exactKeys2(payload, ["attemptId", "verifyCode"]) && validId2(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 exactKeys2(payload, ["botId"]) && validId2(payload.botId) ? null : "bot.reconnect requires a botId."; + } + if (endpoint === WEIXIN_ENDPOINTS.deleteBot) { + return exactKeys2(payload, ["botId", "confirm"]) && validId2(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; + } + return "Unknown Weixin endpoint."; +} +function badRequest3(message) { + return { ok: false, error: { code: "bad-request", message } }; +} +function cancelled3() { + return { ok: false, error: { code: "cancelled", message: "The request was cancelled." } }; +} +function internalFailure3() { + return { + ok: false, + error: { code: "weixin-operation-failed", message: "\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" } + }; +} +async function qrDataUrl2(value) { + return QRCode3.toDataURL(value, { + type: "image/png", + errorCorrectionLevel: "M", + margin: 2, + width: 320 + }); +} +async function withEncodedQr2(value, encodeQr) { + if (!value || !value.verificationUrl) return value; + return { + ...value, + qrCodeDataUrl: await encodeQr(value.verificationUrl) + }; +} +async function publicStatus2(status, encodeQr) { + const safe = structuredClone(status); + if (safe.provisioning) safe.provisioning = await withEncodedQr2(safe.provisioning, encodeQr); + return safe; +} +function assertController3(controller) { + if (!controller || typeof controller.status !== "function" || typeof controller.startProvisioning !== "function" || typeof controller.registrationStatus !== "function" || typeof controller.submitVerification !== "function" || typeof controller.cancelProvisioning !== "function" || typeof controller.reconnectBot !== "function" || typeof controller.deleteBot !== "function") { + throw new TypeError("A complete Weixin controller is required"); + } +} +function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { + assertController3(controller); + 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 cancelled3(); + if (!WEIXIN_RPC_ENDPOINTS.includes(endpoint)) return badRequest3("Unknown Weixin endpoint."); + const invalid = payloadFailure2(endpoint, payload); + if (invalid) return badRequest3(invalid); + try { + let value; + if (endpoint === WEIXIN_ENDPOINTS.status) { + value = await publicStatus2(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 withEncodedQr2(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 withEncodedQr2(current, cachedEncode); + } else if (endpoint === WEIXIN_ENDPOINTS.submitVerification) { + value = await withEncodedQr2( + await controller.submitVerification(payload.attemptId, payload.verifyCode), + cachedEncode + ); + } else if (endpoint === WEIXIN_ENDPOINTS.cancelProvisioning) { + value = await controller.cancelProvisioning(payload.attemptId); + if (!value) return badRequest3("The provisioning attempt no longer exists."); + } else if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) { + value = await publicStatus2(await controller.reconnectBot(payload.botId), cachedEncode); + } else { + value = await publicStatus2(await controller.deleteBot(payload.botId), cachedEncode); + } + return signal?.aborted ? cancelled3() : { ok: true, value }; + } catch { + return signal?.aborted ? cancelled3() : internalFailure3(); + } + }; +} +function installWeixinRpc(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( + WEIXIN_RPC_CHANNEL, + createWeixinRpcHandler(controller, options), + { authority: "loopback" } + ); +} + +// plugin-src/host/channels/weixin/index.mjs +async function apply3(ctx, config = {}) { + if (config?.controller) return installWeixinRpc(ctx, config.controller, config.rpcOptions); + const production = await createProductionController3(ctx, config, config.internals); + const disposeRpc = installWeixinRpc(ctx, production.controller, config.rpcOptions); + ctx.effect(() => async () => { + await production.close(); + }, "dsh-weixin: close account connections"); + return disposeRpc; +} + // plugin-src/host/index.mjs -import { apply as applyDingtalk } from "@xmanrui/dsh-dingtalk"; -import { apply as applyFeishu } from "@xmanrui/dsh-feishu"; -import { apply as applyWeixin } from "@xmanrui/dsh-weixin"; var name = "dsh-im-host"; var inject = ["connection", "credentials", "webServer"]; function createImHostPlugin(internals = {}) { - const startFeishu = internals.applyFeishu ?? applyFeishu; - const startWeixin = internals.applyWeixin ?? applyWeixin; - const startDingtalk = internals.applyDingtalk ?? applyDingtalk; + const startFeishu = internals.applyFeishu ?? apply2; + const startWeixin = internals.applyWeixin ?? apply3; + const startDingtalk = internals.applyDingtalk ?? apply; return Object.freeze({ name, inject, @@ -18,11 +7984,11 @@ function createImHostPlugin(internals = {}) { } }); } -async function apply(ctx, config = {}) { +async function apply4(ctx, config = {}) { return createImHostPlugin().apply(ctx, config); } export { - apply, + apply4 as apply, createImHostPlugin, inject, name diff --git a/package-lock.json b/package-lock.json index 90eeb0e..735c6f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz", - "@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz", - "@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz" + "@larksuiteoapi/node-sdk": "1.73.0", + "dingtalk-stream": "2.1.4", + "qrcode": "1.5.4" }, "bin": { "dsh-im": "bin/dsh-im.mjs" @@ -19,7 +19,8 @@ "devDependencies": { "esbuild": "0.25.9", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "react-test-renderer": "18.3.1" }, "engines": { "node": ">=22.19" @@ -548,54 +549,6 @@ "undici-types": "~8.3.0" } }, - "node_modules/@xmanrui/dsh-dingtalk": { - "version": "0.1.0", - "resolved": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz", - "integrity": "sha512-jQMxICgQueYMRpDcK5R1pcsIxlB1DQNYzZqx2iE/LuhQvBpM7lPF8X/VmkC9Co6Gzgr919KcqJ6HvkO2pzF48w==", - "license": "MIT", - "dependencies": { - "dingtalk-stream": "2.1.4", - "qrcode": "1.5.4" - }, - "bin": { - "dsh-dingtalk": "bin/dsh-dingtalk.mjs" - }, - "engines": { - "node": ">=22.19" - } - }, - "node_modules/@xmanrui/dsh-feishu": { - "version": "0.1.0", - "resolved": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz", - "integrity": "sha512-irnMONxsE9hUzVQDCqtPuOJD7wWHKSvqVhSDmXnQ02EuuxXKCSh/tx7z7Hpttky/ABsLHOOjSVxAW2OCoZnFng==", - "license": "MIT", - "dependencies": { - "@larksuiteoapi/node-sdk": "1.73.0", - "dotenv": "^17.2.3", - "qrcode": "^1.5.4" - }, - "bin": { - "dsh-feishu": "bin/dsh-feishu.mjs" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@xmanrui/dsh-weixin": { - "version": "0.1.0", - "resolved": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz", - "integrity": "sha512-vQoGdq2CO7F5REE1FcSspLGc+mqd/6r/+pdOjC16ECH6/rRzt0A/uTEt/72IHafix8y449xq2NDupxK3qV8vkQ==", - "license": "MIT", - "dependencies": { - "qrcode": "1.5.4" - }, - "bin": { - "dsh-weixin": "bin/dsh-weixin.mjs" - }, - "engines": { - "node": ">=22.19" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -781,18 +734,6 @@ "ws": "^8.13.0" } }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1169,6 +1110,16 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1328,6 +1279,42 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-test-renderer": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz", + "integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^18.3.1", + "react-shallow-renderer": "^16.15.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 1c89afa..352a4a3 100644 --- a/package.json +++ b/package.json @@ -50,20 +50,21 @@ }, "scripts": { "build": "node plugin-src/client/build.mjs && node plugin-src/host/build.mjs", - "test": "node --test test/*.test.mjs", + "test": "node --test test/*.test.mjs test/channels/*/*.test.mjs", "check": "npm test && npm run build && node scripts/verify-package.mjs" }, "engines": { "node": ">=22.19" }, "dependencies": { - "@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz", - "@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz", - "@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz" + "@larksuiteoapi/node-sdk": "1.73.0", + "dingtalk-stream": "2.1.4", + "qrcode": "1.5.4" }, "devDependencies": { "esbuild": "0.25.9", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "react-test-renderer": "18.3.1" } } diff --git a/plugin-src/client/channels/dingtalk/api.js b/plugin-src/client/channels/dingtalk/api.js new file mode 100644 index 0000000..d74ccd9 --- /dev/null +++ b/plugin-src/client/channels/dingtalk/api.js @@ -0,0 +1,202 @@ +export const DINGTALK_RPC_CHANNEL = '/dingtalk'; + +export const DINGTALK_ENDPOINTS = Object.freeze({ + status: 'connection.status', + beginProvisioning: 'provision.begin', + pollProvisioning: 'provision.poll', + cancelProvisioning: 'provision.cancel', + reconnectBot: 'bot.reconnect', + deleteBot: 'bot.delete', +}); + +const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']); +const SNAPSHOT_STATES = new Set(['disconnected', 'offline', 'provisioning', 'connected', 'degraded']); +const PROVISION_STATES = new Set([ + 'starting', + 'pending', + 'scanned', + 'authorizing', + 'creating', + 'connecting', + 'connected', + 'expired', + 'failed', + 'cancelled', +]); +const HEALTH_STATES = new Set(['healthy', 'checking', 'degraded', 'offline']); +const FORBIDDEN_ERROR_FIELDS = /(client[_-]?secret|secret[_-]?ref|device[_-]?code|app[_-]?secret|access[_-]?token|token)/i; +const QR_DATA_URL = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i; +const MAX_QR_SOURCE_LENGTH = 2 * 1024 * 1024; + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function optionalString(value, maxLength = 240) { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, maxLength) : undefined; +} + +function opaqueId(value) { + const id = optionalString(value, 128); + return id && /^[a-z\d_-]+$/i.test(id) ? id : undefined; +} + +function timestamp(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : parsed; + } + return undefined; +} + +function nonNegativeInteger(value) { + const number = Number(value); + return Number.isSafeInteger(number) && number >= 0 ? number : 0; +} + +function clamp(value, min, max, fallback) { + const number = Number(value); + return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; +} + +function safeErrorCode(value, fallback) { + const code = optionalString(value, 80); + return code && /^[a-z][a-z\d_.:-]*$/i.test(code) && !FORBIDDEN_ERROR_FIELDS.test(code) + ? code + : fallback; +} + +function sanitizeMessage(value, fallback) { + const message = optionalString(value, 480) ?? fallback; + if (FORBIDDEN_ERROR_FIELDS.test(message)) return fallback; + return message.replace(/([=:]\s*)[^\s,;,。]+/g, '$1••••••').slice(0, 240); +} + +function normalizeError(value, fallbackCode, fallbackMessage) { + if (!isRecord(value)) return undefined; + return { + code: safeErrorCode(value.code, fallbackCode), + message: sanitizeMessage(value.message, fallbackMessage), + }; +} + +export function unwrapRpcResult(result) { + if (!isRecord(result) || typeof result.ok !== 'boolean') { + throw new Error('钉钉服务返回了无法识别的响应'); + } + if (!result.ok) { + const error = new Error(sanitizeMessage(result.error?.message, '钉钉操作失败')); + error.code = safeErrorCode(result.error?.code, 'DINGTALK_RPC_ERROR'); + throw error; + } + return result.value; +} + +export function safeQrSource(value) { + if (typeof value !== 'string' || value.length > MAX_QR_SOURCE_LENGTH) return undefined; + return 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 = opaqueId(source.attemptId); + if (!attemptId) throw new Error('钉钉扫码服务没有返回有效的绑定任务'); + + const reportedStatus = optionalString(source.status, 32) ?? optionalString(source.state, 32); + const status = PROVISION_STATES.has(reportedStatus) ? reportedStatus : 'failed'; + const expiresAt = timestamp(source.expiresAt) + ?? now + clamp(source.expiresIn, 1, 2 * 60 * 60, 10 * 60) * 1_000; + const result = { + attemptId, + status, + expiresAt, + pollIntervalMs: clamp(source.pollIntervalMs, 1_000, 10_000, 3_000), + }; + const qrCodeDataUrl = safeQrSource(source.qrCodeDataUrl); + if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl; + if (opaqueId(source.botId)) result.botId = opaqueId(source.botId); + if (source.alreadyConnected === true) result.alreadyConnected = true; + const error = normalizeError( + source.error, + 'DINGTALK_PROVISION_FAILED', + '钉钉机器人没有接入完成', + ); + if (error) result.error = error; + return result; +} + +function normalizeBot(value) { + if (!isRecord(value)) return undefined; + const botId = opaqueId(value.botId); + if (!botId) return undefined; + const bot = isRecord(value.bot) ? value.bot : {}; + const connected = value.connected === true; + const reportedState = ACCOUNT_STATES.has(value.state) ? value.state : 'offline'; + const state = connected ? 'connected' : reportedState === 'connected' ? 'connecting' : reportedState; + const health = isRecord(value.health) ? value.health : {}; + const stats = isRecord(value.stats) ? value.stats : {}; + return { + botId, + state, + connected, + configured: value.configured !== false, + bot: { + name: optionalString(bot.name, 100) ?? '钉钉机器人', + clientIdMasked: optionalString(bot.clientIdMasked, 140) ?? '已安全保存', + }, + health: { + status: HEALTH_STATES.has(health.status) + ? health.status + : connected ? 'healthy' : 'offline', + summary: optionalString(health.summary, 200) + ?? (connected ? '钉钉 Stream 长连接运行正常' : '钉钉连接尚未就绪'), + lastCheckedAt: timestamp(health.lastCheckedAt), + lastConnectedAt: timestamp(health.lastConnectedAt), + }, + stats: { + messagesReceived: nonNegativeInteger(stats.messagesReceived), + messagesReplied: nonNegativeInteger(stats.messagesReplied), + }, + error: normalizeError(value.error, 'DINGTALK_ACCOUNT_ERROR', '钉钉连接尚未就绪') ?? null, + }; +} + +export function normalizeSnapshot(value) { + const source = isRecord(value?.snapshot) ? value.snapshot : value; + if (!isRecord(source) || !Array.isArray(source.bots)) { + throw new Error('钉钉服务没有返回有效的机器人列表'); + } + const seen = new Set(); + const bots = source.bots.map(normalizeBot).filter((bot) => { + if (!bot || seen.has(bot.botId)) return false; + seen.add(bot.botId); + return true; + }); + return { + schemaVersion: Number.isSafeInteger(source.schemaVersion) ? source.schemaVersion : 1, + revision: nonNegativeInteger(source.revision), + state: SNAPSHOT_STATES.has(source.state) ? source.state : 'offline', + 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: safeErrorCode(error?.code, 'DINGTALK_ERROR'), + message: sanitizeMessage(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/dingtalk/index.js b/plugin-src/client/channels/dingtalk/index.js new file mode 100644 index 0000000..e94e052 --- /dev/null +++ b/plugin-src/client/channels/dingtalk/index.js @@ -0,0 +1,661 @@ +import * as React from 'react'; + +import { + DINGTALK_ENDPOINTS, + DINGTALK_RPC_CHANNEL, + formatRemaining, + normalizeProvisioning, + normalizeSnapshot, + presentError, + safeQrSource, + unwrapRpcResult, +} from './api.js'; +import { installDingtalkStyles } from './styles.js'; + +const h = React.createElement; +const ACTIVE_PROVISION_STATES = new Set(['pending', 'scanned', 'authorizing', 'creating', 'connecting']); + +export const name = 'dingtalk-settings'; +export const inject = ['slots', 'connection']; + +function DingtalkIcon({ size = 28 }) { + return h('svg', { + width: size, + height: size, + viewBox: '0 0 48 48', + fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', + 'aria-hidden': 'true', + focusable: 'false', + }, h('path', { + fill: 'currentColor', + d: 'M37.05 22.783c-6.758-5.216-14.378-12.128-22.73-19.538-.655-.585-1.242-.354-1.536.42-1.88 4.973-.058 9.386 2.889 11.932s7.368 4.912 10.058 6.155c.105.049.013.203-.093.163-4.953-2.182-8.397-3.765-13.07-7.368-.497-.388-1.01-.242-1.07.521-.384 4.748 2.657 8.483 6.058 9.745 2.1.781 4.398 1.212 6.53 1.474.109.015.084.178-.027.178-2.747.01-6.058-.654-8.935-1.751-.606-.233-.818.25-.722.633.491 2.008 2.974 5.076 6.926 5.73a12 12 0 0 0 2.228.115c.164 0 .208.089.154.217q-2.685 4.6-2.803 4.797c-.091.152-.036.275.156.275h3.543c.164 0 .264.106.18.246l-4.958 8.196c-.191.328.035.565.395.301s15.212-11.133 15.636-11.448c.195-.142.148-.327-.124-.327h-3.18c-.206 0-.252-.14-.111-.28.14-.141 3.602-3.594 4.837-4.888 1.283-1.35 1.938-3.825-.231-5.498', + })); +} + +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 Heading({ totals, adding, busy, onAdd, addButtonRef }) { + return h('div', { className: 'ddt-heading' }, + h('div', { className: 'ddt-headingCopy' }, + h('div', { className: 'ddt-eyebrow' }, 'Channel'), + h('h2', null, '钉钉机器人'), + h('p', null, '通过扫码把钉钉机器人接入 DeepSeek Harness')), + h('div', { className: 'ddt-tools' }, + totals.configured > 0 + ? h('div', { className: 'ddt-badge' }, + h('span', { + className: 'ddt-dot', + 'data-tone': totals.connected > 0 ? 'success' : 'warning', + }), + h('span', null, `${totals.connected} / ${totals.configured} 在线`)) + : null, + h('div', { + className: 'ddt-badge', + title: '应用密钥只写入 Harness Host 凭据服务,不会发送到浏览器', + }, '凭据仅保存在本机'), + h(Button, { + kind: 'primary', + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + }, adding ? '正在接入' : '扫码接入钉钉'))); +} + +function LoadingView() { + return h('div', { className: 'ddt-card ddt-loading', 'aria-busy': 'true' }, + h('div', { className: 'ddt-spinner' }), + h('span', null, '正在读取钉钉连接状态…')); +} + +function EmptyView({ busy, onStart }) { + return h('div', { className: 'ddt-card' }, + h('div', { className: 'ddt-cardBody ddt-empty' }, + h('div', null, + h('div', { className: 'ddt-stateLabel' }, + h('span', { className: 'ddt-dot' }), h('span', null, '尚未接入钉钉机器人')), + h('h3', null, '扫一次码,自动创建并连接机器人'), + h('p', null, '授权由钉钉官方页面完成。扫码账号必须已加入一个企业/组织并有权创建机器人;创建成功后,应用凭据会直接写入 Harness Host。'), + h('div', { className: 'ddt-actions' }, + h(Button, { kind: 'primary', onClick: onStart, disabled: busy }, + busy ? '正在生成二维码…' : '生成钉钉二维码'))), + h('div', { className: 'ddt-brandMark', 'aria-hidden': 'true' }, + h(DingtalkIcon, { size: 68 })))); +} + +function QrPanel({ provision, now, busy, onRefresh, onCancel }) { + const [imageFailed, setImageFailed] = React.useState(false); + const source = safeQrSource(provision.qrCodeDataUrl); + const remaining = Math.max(0, provision.expiresAt - now); + const expired = remaining === 0 || provision.status === 'expired'; + const duration = Math.max(1, provision.durationMs ?? 10 * 60_000); + const progress = Math.round(Math.min(1, remaining / duration) * 100); + + React.useEffect(() => setImageFailed(false), [source]); + + return h('div', { className: 'ddt-card' }, + h('div', { className: 'ddt-cardBody ddt-qrLayout' }, + h('div', { className: 'ddt-qrColumn' }, + h('div', { className: 'ddt-qrFrame' }, + source && !imageFailed + ? h('img', { + src: source, + alt: '用于把钉钉机器人接入 DeepSeek Harness 的一次性二维码', + onError: () => setImageFailed(true), + }) + : h('div', { className: 'ddt-qrFallback' }, '二维码图片未就绪,请重新生成。'), + expired ? h('div', { className: 'ddt-expired' }, '二维码已过期\n请重新生成') : null), + h('div', { className: 'ddt-countdown' }, + h('div', { className: 'ddt-countdownTop' }, + h('span', null, '二维码有效时间'), h('strong', null, formatRemaining(remaining))), + h('div', { className: 'ddt-progress', 'aria-hidden': 'true' }, + h('span', { style: { '--ddt-progress': `${progress}%` } })))), + h('div', { className: 'ddt-qrCopy' }, + h('div', { className: 'ddt-stateLabel' }, + h('span', { className: 'ddt-dot', 'data-tone': expired ? 'error' : 'warning' }), + h('span', null, expired ? '二维码已失效' : '等待钉钉扫码授权')), + h('h3', null, expired ? '重新生成二维码后继续' : '使用钉钉 App 完成机器人授权'), + h('p', null, '扫码账号必须已加入企业/组织。如果钉钉提示尚未加入组织,请在提示页创建组织,或换用已加入组织的账号。'), + h('ol', { className: 'ddt-steps' }, + h('li', null, '使用已加入企业/组织的钉钉账号扫描左侧二维码'), + h('li', null, '在授权页点击“一键创建新机器人”'), + h('li', null, '保持本页打开,等待机器人自动连接')), + h('div', { className: 'ddt-brandNotice' }, + '钉钉官方授权页目前可能显示 OpenClaw 品牌,这是官方连接器授权页面,不影响机器人接入 DeepSeek Harness。'), + h('div', { className: 'ddt-actions' }, + expired + ? h(Button, { kind: 'primary', onClick: onRefresh, disabled: busy }, '重新生成二维码') + : null, + !expired ? h(Button, { onClick: onRefresh, disabled: busy }, '换一个二维码') : null, + h(Button, { onClick: onCancel, disabled: busy }, '取消'))))); +} + +function ProgressPanel({ status, busy, onCancel }) { + const connecting = status === 'connecting'; + const creating = status === 'creating'; + return h('div', { className: 'ddt-card ddt-loading', 'aria-busy': 'true' }, + h('div', { className: 'ddt-spinner' }), + h('h3', null, connecting + ? '机器人已创建,正在建立消息连接' + : creating ? '授权已确认,正在创建钉钉机器人' : '正在确认钉钉授权'), + h('p', null, connecting + ? '正在检查钉钉 Stream 长连接,成功后会自动显示为在线。' + : '请勿关闭本页,钉钉完成授权后将自动继续。'), + h('div', { className: 'ddt-actions', style: { justifyContent: 'center', marginTop: 14 } }, + h(Button, { onClick: onCancel, disabled: busy }, '取消接入'))); +} + +function ProvisionError({ provision, busy, onRetry, onClose }) { + const error = provision.error ?? { + code: 'DINGTALK_PROVISION_FAILED', + message: '钉钉机器人没有接入完成', + }; + return h('div', { className: 'ddt-card' }, + h('div', { className: 'ddt-inlineError', role: 'alert' }, + h('h3', null, provision.status === 'expired' ? '二维码已过期' : '钉钉机器人没有接入完成'), + h('p', null, error.message), + h('span', { className: 'ddt-errorCode' }, error.code), + h('div', { className: 'ddt-actions' }, + h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'), + h(Button, { onClick: onClose, disabled: busy }, '关闭')))); +} + +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 RemoveConfirmation({ account, busy, onConfirm, onCancel }) { + const cancelRef = React.useRef(null); + React.useEffect(() => cancelRef.current?.focus(), []); + return h('div', { + className: 'ddt-confirm', + role: 'alertdialog', + 'aria-label': `移除${account.bot.name}`, + onKeyDown: (event) => { + if (event.key === 'Escape' && !busy) onCancel(); + }, + }, + h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`), + h('p', null, '这会停止消息连接,并删除本机保存的应用凭据、机器人配置及会话映射。钉钉开放平台中的机器人不会被自动删除。'), + h('div', { className: 'ddt-actions' }, + h(Button, { ref: cancelRef, onClick: onCancel, disabled: busy }, '保留机器人'), + h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, + busy ? '正在移除…' : '确认移除接入'))); +} + +function AccountCard({ + account, + busy, + removing, + onReconnect, + onRequestRemove, + onConfirmRemove, + onCancelRemove, +}) { + const state = busy === 'reconnect' ? 'connecting' : account.state; + const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning'; + const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪'; + return h('article', { className: 'ddt-card', tabIndex: -1, 'data-bot-id': account.botId }, + h('div', { className: 'ddt-cardBody' }, + h('div', { className: 'ddt-accountTop' }, + h('div', { className: 'ddt-accountIdentity' }, + h('div', { className: 'ddt-avatar', 'aria-hidden': 'true' }, h(DingtalkIcon, { size: 29 })), + h('div', null, + h('h3', { title: account.bot.name }, account.bot.name), + h('p', { title: account.bot.clientIdMasked }, account.bot.clientIdMasked))), + h('div', { className: 'ddt-health' }, + h('span', { className: 'ddt-dot', 'data-tone': tone }), h('span', null, stateLabel))), + h('dl', { className: 'ddt-metrics' }, + h('div', { className: 'ddt-metric' }, h('dt', null, '消息通道'), + h('dd', null, account.connected ? 'Stream 长连接' : '离线')), + h('div', { className: 'ddt-metric' }, h('dt', null, '收到 / 回复'), + h('dd', null, `${account.stats.messagesReceived} / ${account.stats.messagesReplied}`)), + h('div', { className: 'ddt-metric' }, h('dt', null, '最近检查'), + h('dd', null, checkedTime(account.health.lastCheckedAt)))), + h('div', { className: 'ddt-accountFooter' }, + h('div', { className: 'ddt-summary' }, account.error?.message ?? account.health.summary), + h('div', { className: 'ddt-actions' }, + h(Button, { onClick: onReconnect, disabled: Boolean(busy) }, + busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'), + h(Button, { kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, + '移除接入')))), + removing ? h(RemoveConfirmation, { + account, + busy: busy === 'delete', + onConfirm: onConfirmRemove, + onCancel: onCancelRemove, + }) : null); +} + +function AccountList(props) { + return h('section', null, + h('div', { className: 'ddt-listHeading' }, + h('h3', null, '已接入的钉钉机器人'), h('span', null, `${props.bots.length} 个`)), + h('ul', { className: 'ddt-list' }, props.bots.map((account) => h('li', { key: account.botId }, + h(AccountCard, { + account, + busy: props.busyByBot[account.botId], + removing: props.removeTarget === account.botId, + onReconnect: () => props.onReconnect(account), + onRequestRemove: () => props.onRequestRemove(account), + onConfirmRemove: () => props.onConfirmRemove(account), + onCancelRemove: props.onCancelRemove, + }))))); +} + +const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 }); + +export function DingtalkSettingsTab({ rpcCall }) { + const [model, setModel] = React.useState({ + phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 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 [notice, setNotice] = React.useState(''); + const [now, setNow] = React.useState(() => Date.now()); + const addButtonRef = React.useRef(null); + const mountedRef = React.useRef(true); + const statusRequestRef = React.useRef(0); + const noticeFrameRef = React.useRef(null); + const focusFrameRef = React.useRef(null); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + statusRequestRef.current += 1; + if (noticeFrameRef.current !== null) { + window.cancelAnimationFrame(noticeFrameRef.current); + noticeFrameRef.current = null; + } + if (focusFrameRef.current !== null) { + window.cancelAnimationFrame(focusFrameRef.current); + focusFrameRef.current = null; + } + }; + }, []); + + React.useEffect(() => installDingtalkStyles(), []); + + const announce = React.useCallback((message) => { + if (!mountedRef.current) return; + if (noticeFrameRef.current !== null) { + window.cancelAnimationFrame(noticeFrameRef.current); + noticeFrameRef.current = null; + } + setNotice(''); + if (message) { + noticeFrameRef.current = window.requestAnimationFrame(() => { + noticeFrameRef.current = null; + if (mountedRef.current) setNotice(message); + }); + } + }, []); + + const focusAddButton = React.useCallback(() => { + if (!mountedRef.current) return; + if (focusFrameRef.current !== null) window.cancelAnimationFrame(focusFrameRef.current); + focusFrameRef.current = window.requestAnimationFrame(() => { + focusFrameRef.current = null; + if (mountedRef.current) addButtonRef.current?.focus(); + }); + }, []); + + const invoke = React.useCallback(async (endpoint, payload = {}, signal) => { + if (typeof rpcCall !== 'function') throw new TypeError('钉钉设置页缺少 RPC 连接'); + return unwrapRpcResult(await rpcCall(endpoint, payload, signal)); + }, [rpcCall]); + + const loadStatus = React.useCallback(async ({ + signal, + silent = false, + restoreProvisioning = false, + } = {}) => { + if (!mountedRef.current || signal?.aborted) return undefined; + const requestId = statusRequestRef.current + 1; + statusRequestRef.current = requestId; + const canCommit = () => mountedRef.current + && !signal?.aborted + && statusRequestRef.current === requestId; + if (!silent && canCommit()) { + setModel((current) => ({ ...current, phase: 'loading', error: null })); + } + try { + const snapshot = normalizeSnapshot(await invoke(DINGTALK_ENDPOINTS.status, {}, signal)); + if (!canCommit()) return undefined; + setModel({ + phase: 'ready', + bots: snapshot.bots, + totals: snapshot.totals, + revision: snapshot.revision, + error: null, + }); + if (restoreProvisioning && snapshot.provisioning) { + setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId + ? { + ...current, + ...snapshot.provisioning, + durationMs: current?.durationMs + ?? Math.max(1, snapshot.provisioning.expiresAt - Date.now()), + } + : current); + } + return snapshot; + } catch (error) { + if (error?.name === 'AbortError' || !canCommit()) return undefined; + setModel((current) => ({ + ...current, + phase: silent && current.phase === 'ready' ? 'ready' : 'error', + error: presentError(error), + })); + return undefined; + } + }, [invoke]); + + React.useEffect(() => { + const controller = new AbortController(); + void loadStatus({ signal: controller.signal, restoreProvisioning: true }); + return () => controller.abort(); + }, [loadStatus]); + + React.useEffect(() => { + if (model.phase !== 'ready') return undefined; + const controller = new AbortController(); + let running = false; + const timer = window.setInterval(async () => { + if (running || controller.signal.aborted || !mountedRef.current) return; + running = true; + await loadStatus({ + signal: controller.signal, + silent: true, + restoreProvisioning: false, + }); + running = false; + }, 15_000); + return () => { + controller.abort(); + window.clearInterval(timer); + }; + }, [loadStatus, model.phase]); + + React.useEffect(() => { + if (!provision || !ACTIVE_PROVISION_STATES.has(provision.status)) return undefined; + const timer = window.setInterval(() => { + if (mountedRef.current) setNow(Date.now()); + }, 1_000); + return () => window.clearInterval(timer); + }, [provision?.attemptId, provision?.status]); + + const startProvisioning = React.useCallback(async ({ replace = false } = {}) => { + if (!mountedRef.current) return; + setBusy(true); + try { + if (replace && provision?.attemptId) { + await invoke(DINGTALK_ENDPOINTS.cancelProvisioning, { + attemptId: provision.attemptId, + }); + if (!mountedRef.current) return; + } + setProvision({ status: 'starting' }); + const started = normalizeProvisioning(await invoke( + DINGTALK_ENDPOINTS.beginProvisioning, + { locale: 'zh-CN' }, + )); + if (!mountedRef.current) return; + if (!started.qrCodeDataUrl) { + throw new Error('钉钉扫码服务没有返回安全的二维码'); + } + setNow(Date.now()); + setProvision({ + ...started, + durationMs: Math.max(1, started.expiresAt - Date.now()), + }); + announce('钉钉二维码已生成,请使用钉钉 App 扫描。'); + } catch (error) { + if (!mountedRef.current) return; + setProvision({ + attemptId: provision?.attemptId, + status: 'failed', + error: presentError(error), + }); + } finally { + if (mountedRef.current) setBusy(false); + } + }, [announce, invoke, provision?.attemptId]); + + const cancelProvisioning = React.useCallback(async () => { + if (!mountedRef.current) return; + setBusy(true); + try { + if (provision?.attemptId && !['failed', 'expired', 'cancelled'].includes(provision.status)) { + await invoke(DINGTALK_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + if (!mountedRef.current) return; + } + setProvision(null); + announce('已取消钉钉机器人接入。'); + focusAddButton(); + } catch (error) { + if (!mountedRef.current) return; + setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) })); + } finally { + if (mountedRef.current) setBusy(false); + } + }, [announce, focusAddButton, invoke, provision?.attemptId, provision?.status]); + + React.useEffect(() => { + const attemptId = provision?.attemptId; + if (!attemptId || !ACTIVE_PROVISION_STATES.has(provision.status)) return undefined; + const controller = new AbortController(); + let disposed = false; + let timer = null; + const canCommit = () => !disposed && !controller.signal.aborted && mountedRef.current; + const schedule = (delay) => { + if (!canCommit()) return; + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + timer = null; + if (canCommit()) void poll(); + }, delay); + }; + const poll = async () => { + try { + const response = await invoke( + DINGTALK_ENDPOINTS.pollProvisioning, + { attemptId }, + controller.signal, + ); + if (!canCommit()) return; + const result = normalizeProvisioning(response); + if (result.status === 'connected') { + const snapshot = await loadStatus({ + signal: controller.signal, + silent: true, + restoreProvisioning: false, + }); + if (!canCommit()) return; + const account = result.botId + ? snapshot?.bots.find((bot) => bot.botId === result.botId) + : snapshot?.bots.find((bot) => bot.connected); + if (!account?.connected) { + setProvision((current) => current?.attemptId === attemptId + ? { ...current, ...result, status: 'connecting' } + : current); + schedule(result.pollIntervalMs); + return; + } + setProvision(null); + announce(result.alreadyConnected + ? '这个钉钉机器人已经接入并保持在线。' + : '钉钉机器人已接入,可以开始发送消息。'); + return; + } + if (!canCommit()) return; + setProvision((current) => current?.attemptId === attemptId + ? { ...current, ...result, durationMs: current.durationMs } + : current); + if (ACTIVE_PROVISION_STATES.has(result.status)) { + schedule(result.pollIntervalMs); + } + } catch (error) { + if (error?.name === 'AbortError' || !canCommit()) return; + setProvision((current) => current?.attemptId === attemptId + ? { ...current, status: 'failed', error: presentError(error) } + : current); + } + }; + schedule(provision.pollIntervalMs ?? 3_000); + return () => { + disposed = true; + controller.abort(); + if (timer !== null) window.clearTimeout(timer); + timer = null; + }; + }, [announce, invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); + + const setBotBusy = React.useCallback((botId, operation) => { + if (!mountedRef.current) return; + setBusyByBot((current) => { + const next = { ...current }; + if (operation) next[botId] = operation; + else delete next[botId]; + return next; + }); + }, []); + + const runBotAction = React.useCallback(async ({ account, operation, endpoint, payload, success }) => { + if (!mountedRef.current) return undefined; + setBotBusy(account.botId, operation); + try { + await invoke(endpoint, payload); + if (!mountedRef.current) return undefined; + const snapshot = await loadStatus({ silent: true, restoreProvisioning: false }); + if (!mountedRef.current) return undefined; + announce(typeof success === 'function' ? success(snapshot) : success); + return snapshot; + } catch (error) { + if (!mountedRef.current) return undefined; + announce(`操作失败:${presentError(error).message}`); + return undefined; + } finally { + if (mountedRef.current) setBotBusy(account.botId, null); + } + }, [announce, invoke, loadStatus, setBotBusy]); + + const reconnect = React.useCallback((account) => runBotAction({ + account, + operation: 'reconnect', + endpoint: DINGTALK_ENDPOINTS.reconnectBot, + payload: { botId: account.botId }, + success: (snapshot) => snapshot?.bots.find((bot) => bot.botId === account.botId)?.connected + ? '钉钉连接检查完成。' + : '钉钉仍未连接,插件会继续自动重试。', + }), [runBotAction]); + + const remove = React.useCallback(async (account) => { + const snapshot = await runBotAction({ + account, + operation: 'delete', + endpoint: DINGTALK_ENDPOINTS.deleteBot, + payload: { botId: account.botId, confirm: true }, + success: '钉钉机器人及本机凭据已移除。', + }); + if (snapshot && mountedRef.current) setRemoveTarget(null); + }, [runBotAction]); + + let provisionView = null; + if (provision?.status === 'starting') { + provisionView = h('div', { className: 'ddt-card ddt-loading', 'aria-busy': 'true' }, + h('div', { className: 'ddt-spinner' }), h('span', null, '正在申请钉钉授权二维码…')); + } else if (provision?.status === 'pending') { + provisionView = h(QrPanel, { + provision, + now, + busy, + onRefresh: () => void startProvisioning({ replace: true }), + onCancel: () => void cancelProvisioning(), + }); + } else if (['scanned', 'authorizing', 'creating', 'connecting'].includes(provision?.status)) { + provisionView = h(ProgressPanel, { + status: provision.status, + busy, + onCancel: () => void cancelProvisioning(), + }); + } else if (provision && ['failed', 'expired', 'cancelled'].includes(provision.status)) { + provisionView = h(ProvisionError, { + provision, + busy, + onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }), + onClose: () => void cancelProvisioning(), + }); + } + + return h('section', { className: 'ddt-page', 'aria-label': '钉钉设置' }, + h(Heading, { + totals: model.totals, + adding: Boolean(provision), + busy, + onAdd: () => void startProvisioning(), + addButtonRef, + }), + h('div', { className: 'ddt-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice), + model.error && model.phase === 'ready' + ? h('div', { className: 'ddt-statusNotice', role: 'alert' }, `状态刷新失败:${model.error.message}`) + : null, + model.phase === 'loading' + ? h(LoadingView) + : model.phase === 'error' + ? h('div', { className: 'ddt-card' }, + h('div', { className: 'ddt-inlineError', role: 'alert' }, + 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, + model.bots.length > 0 + ? h(AccountList, { + bots: model.bots, + busyByBot, + removeTarget, + onReconnect: (account) => void reconnect(account), + onRequestRemove: (account) => setRemoveTarget(account.botId), + onConfirmRemove: (account) => void remove(account), + onCancelRemove: () => setRemoveTarget(null), + }) + : null)); +} + +export function apply(ctx) { + ctx.effect(() => installDingtalkStyles(), 'dingtalk-settings: install client styles'); + const rpcCall = (endpoint, payload, signal) => + ctx.connection.rpc.call(DINGTALK_RPC_CHANNEL, endpoint, payload, signal); + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ + name: 'settings.plugins.tab', + id: 'dingtalk', + order: 40, + label: '钉钉', + inject: () => ({ rpcCall }), + }, DingtalkSettingsTab)); +} diff --git a/plugin-src/client/channels/dingtalk/styles.js b/plugin-src/client/channels/dingtalk/styles.js new file mode 100644 index 0000000..fa5dc57 --- /dev/null +++ b/plugin-src/client/channels/dingtalk/styles.js @@ -0,0 +1,141 @@ +export const DINGTALK_STYLE_ID = 'xmanrui-dsh-dingtalk-settings'; + +const CSS = String.raw` +.ddt-page { + --ddt-accent: #1677ff; + --ddt-accent-deep: #0958d9; + --ddt-accent-wash: #eaf3ff; + --ddt-success: var(--dsw-alias-state-success-primary, #20a162); + --ddt-warning: var(--dsw-alias-state-warning-primary, #d97706); + --ddt-error: var(--dsw-alias-state-error-primary, #d54941); + width: 100%; + max-width: 880px; + display: flex; + flex-direction: column; + gap: 18px; + padding: 2px 0 28px; + container-type: inline-size; + color: var(--dsw-alias-label-primary, #1f2329); + box-sizing: border-box; +} +.ddt-page *, .ddt-page *::before, .ddt-page *::after { box-sizing: border-box; } +.ddt-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; } +.ddt-headingCopy { min-width: 0; } +.ddt-heading h2, .ddt-heading p, .ddt-card h3, .ddt-card h4, .ddt-card p { margin: 0; } +.ddt-eyebrow { margin-bottom: 3px; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; } +.ddt-heading h2 { font-size: 20px; line-height: 28px; font-weight: 680; } +.ddt-heading p { margin-top: 5px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; white-space: nowrap; } +.ddt-tools, .ddt-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; } +.ddt-tools { justify-content: flex-end; } +.ddt-badge { min-height: 30px; display: inline-flex; align-items: center; gap: 7px; padding: 0 11px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-fill-secondary, #f2f3f5); font-size: 12px; white-space: nowrap; } +.ddt-dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #aeb3bb; } +.ddt-dot[data-tone="success"] { background: var(--ddt-success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ddt-success) 14%, transparent); } +.ddt-dot[data-tone="warning"] { background: var(--ddt-warning); } +.ddt-dot[data-tone="error"] { background: var(--ddt-error); } +.ddt-button { min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 0 13px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: inherit; font-size: 13px; font-weight: 560; text-decoration: none; cursor: pointer; transition: border-color .15s ease, background .15s ease, transform .15s ease; } +.ddt-button:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-fill-tertiary, #f7f8fa); } +.ddt-button:active:not(:disabled) { transform: translateY(1px); } +.ddt-button:focus-visible { outline: 2px solid color-mix(in srgb, var(--ddt-accent) 70%, white); outline-offset: 2px; } +.ddt-button:disabled { cursor: not-allowed; opacity: .55; } +.ddt-button[data-kind="primary"] { color: #fff; border-color: var(--ddt-accent); background: var(--ddt-accent); } +.ddt-button[data-kind="primary"]:hover:not(:disabled) { border-color: var(--ddt-accent-deep); background: var(--ddt-accent-deep); } +.ddt-button[data-kind="danger"] { color: var(--ddt-error); } +.ddt-button[data-kind="quiet"] { min-height: 30px; padding: 0 10px; border-color: transparent; background: transparent; } +.ddt-card { overflow: hidden; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-body, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 3%); } +.ddt-cardBody { padding: 24px; } +.ddt-empty { min-height: 230px; display: grid; grid-template-columns: minmax(0, 1fr) 180px; align-items: center; gap: 30px; } +.ddt-empty h3 { margin: 8px 0; font-size: 18px; } +.ddt-empty p { max-width: 560px; color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.65; } +.ddt-empty .ddt-actions { margin-top: 20px; } +.ddt-brandMark { width: 110px; height: 110px; display: grid; place-items: center; justify-self: center; border-radius: 28px; color: #fff; background: linear-gradient(145deg, #2997ff, var(--ddt-accent)); box-shadow: 0 18px 45px rgb(22 119 255 / 23%); } +.ddt-brandMark svg { filter: drop-shadow(0 3px 8px rgb(0 35 96 / 16%)); } +.ddt-qrLayout { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 34px; align-items: start; } +.ddt-qrColumn { display: flex; flex-direction: column; align-items: center; gap: 12px; } +.ddt-qrFrame { position: relative; width: min(270px, 100%); aspect-ratio: 1; display: grid; place-items: center; overflow: hidden; padding: 10px; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); border-radius: 16px; background: #fff; } +.ddt-qrFrame::before { content: ''; position: absolute; inset: 6px; border: 1px solid rgb(22 119 255 / 10%); border-radius: 11px; pointer-events: none; } +.ddt-qrFrame img { display: block; width: 100%; height: 100%; object-fit: contain; } +.ddt-qrFallback { padding: 24px; color: #646a73; text-align: center; } +.ddt-expired { position: absolute; inset: 0; display: grid; place-items: center; padding: 30px; color: #fff; text-align: center; font-weight: 650; white-space: pre-line; background: rgb(31 35 41 / 76%); backdrop-filter: blur(3px); } +.ddt-countdown { width: min(270px, 100%); color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; } +.ddt-countdownTop { display: flex; justify-content: space-between; margin-bottom: 6px; } +.ddt-countdown strong { color: var(--dsw-alias-label-primary, #1f2329); font-variant-numeric: tabular-nums; } +.ddt-progress { height: 4px; overflow: hidden; border-radius: 99px; background: #eef0f3; } +.ddt-progress span { display: block; width: var(--ddt-progress); height: 100%; background: var(--ddt-accent); transition: width .2s linear; } +.ddt-stateLabel { display: inline-flex; align-items: center; gap: 8px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; font-weight: 600; } +.ddt-qrCopy { min-width: 0; overflow-wrap: anywhere; } +.ddt-qrCopy h3 { margin: 9px 0 8px; font-size: 18px; } +.ddt-qrCopy > p { color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.65; } +.ddt-steps { margin: 18px 0 16px; padding: 0; list-style: none; counter-reset: ddt-step; } +.ddt-steps li { position: relative; min-height: 28px; padding: 3px 0 3px 36px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 22px; counter-increment: ddt-step; } +.ddt-steps li::before { content: counter(ddt-step); position: absolute; left: 0; top: 1px; width: 26px; height: 26px; display: grid; place-items: center; border-radius: 8px; color: var(--ddt-accent-deep); background: var(--ddt-accent-wash); font-size: 12px; font-weight: 700; } +.ddt-brandNotice { margin: 0 0 18px; padding: 10px 12px; border-left: 3px solid #91caff; border-radius: 0 8px 8px 0; color: var(--dsw-alias-label-secondary, #646a73); background: #f5f9ff; font-size: 12px; line-height: 1.55; } +.ddt-loading { padding: 38px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; } +.ddt-loading h3 { margin: 0 0 7px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; } +.ddt-loading p { line-height: 1.6; } +.ddt-spinner { width: 24px; height: 24px; margin: 0 auto 13px; border: 3px solid #e6e8eb; border-top-color: var(--ddt-accent); border-radius: 50%; animation: ddt-spin .8s linear infinite; } +.ddt-statusNotice, .ddt-inlineError { display: flex; align-items: flex-start; gap: 10px; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--ddt-error) 28%, transparent); border-radius: 10px; color: var(--ddt-error); background: color-mix(in srgb, var(--ddt-error) 7%, transparent); font-size: 13px; } +.ddt-inlineError { flex-direction: column; padding: 22px; } +.ddt-inlineError h3 { font-size: 17px; } +.ddt-inlineError p { line-height: 1.55; } +.ddt-errorCode { font: 11px ui-monospace, SFMono-Regular, monospace; opacity: .8; } +.ddt-listHeading { display: flex; align-items: center; justify-content: space-between; margin: 2px 0 9px; } +.ddt-listHeading h3 { margin: 0; font-size: 14px; } +.ddt-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; } +.ddt-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; } +.ddt-accountTop { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.ddt-accountIdentity { min-width: 0; display: flex; align-items: center; gap: 12px; } +.ddt-avatar { width: 42px; height: 42px; display: grid; place-items: center; flex: none; border-radius: 12px; color: #fff; background: linear-gradient(145deg, #2997ff, var(--ddt-accent)); } +.ddt-accountIdentity h3 { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } +.ddt-accountIdentity p { margin-top: 4px; color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; } +.ddt-health { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; } +.ddt-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 20px 0; } +.ddt-metric { min-width: 0; padding: 12px; border-radius: 9px; background: var(--dsw-alias-fill-tertiary, #f7f8fa); } +.ddt-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; } +.ddt-metric dd { overflow: hidden; margin: 5px 0 0; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.ddt-accountFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-line-divider, #eef0f3); } +.ddt-summary { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; } +.ddt-confirm { padding: 18px 24px; border-top: 1px solid color-mix(in srgb, var(--ddt-error) 25%, transparent); background: color-mix(in srgb, var(--ddt-error) 5%, transparent); } +.ddt-confirm strong { display: block; margin-bottom: 6px; font-size: 14px; } +.ddt-confirm p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.55; } +.ddt-confirm .ddt-actions { margin-top: 13px; } +.ddt-visuallyHidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +@keyframes ddt-spin { to { transform: rotate(360deg); } } +@container (max-width: 680px) { + .ddt-heading { flex-direction: column; align-items: stretch; } + .ddt-tools { width: 100%; flex-wrap: nowrap; gap: 6px; } + .ddt-tools .ddt-badge { min-height: 34px; padding-inline: 8px; } + .ddt-tools .ddt-button { flex: none; padding-inline: 10px; white-space: nowrap; } + .ddt-empty { grid-template-columns: minmax(0, 1fr); } + .ddt-brandMark { display: none; } + .ddt-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; gap: 24px; } + .ddt-qrColumn { width: 100%; min-width: 0; } + .ddt-qrCopy { width: 100%; } + .ddt-metrics { gap: 8px; } + .ddt-metric { padding: 10px; } +} +@media (max-width: 720px) { + .ddt-heading, .ddt-accountTop, .ddt-accountFooter { flex-direction: column; align-items: stretch; } + .ddt-heading p { white-space: normal; } + .ddt-tools { justify-content: flex-start; } + .ddt-empty { grid-template-columns: minmax(0, 1fr); } + .ddt-brandMark { display: none; } + .ddt-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; } + .ddt-qrCopy { width: 100%; } + .ddt-cardBody { padding: 20px; } +} +@media (prefers-reduced-motion: reduce) { + .ddt-page *, .ddt-page *::before, .ddt-page *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } +} +`; + +export function installDingtalkStyles() { + if (typeof document === 'undefined') return () => {}; + const existing = document.querySelector(`style[data-plugin-css="${DINGTALK_STYLE_ID}"]`); + if (existing) return () => {}; + const style = document.createElement('style'); + style.dataset.plugin = '@xmanrui/dsh-dingtalk'; + style.dataset.pluginCss = DINGTALK_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 7e455ed..60e67ad 100644 --- a/plugin-src/client/index.js +++ b/plugin-src/client/index.js @@ -1,7 +1,7 @@ import * as React from 'react'; -import { DINGTALK_RPC_CHANNEL } from '@xmanrui/dsh-dingtalk/client-api'; -import { DingtalkSettingsTab } from '@xmanrui/dsh-dingtalk/client-source'; +import { DINGTALK_RPC_CHANNEL } from './channels/dingtalk/api.js'; +import { DingtalkSettingsTab } from './channels/dingtalk/index.js'; import { FeishuSettingsTab } from './channels/feishu/index.js'; import { FEISHU_RPC_CHANNEL } from './channels/feishu/api.js'; import { installFeishuStyles } from './channels/feishu/styles.js'; diff --git a/plugin-src/host/channels/dingtalk/connection-supervisor.mjs b/plugin-src/host/channels/dingtalk/connection-supervisor.mjs new file mode 100644 index 0000000..532ac72 --- /dev/null +++ b/plugin-src/host/channels/dingtalk/connection-supervisor.mjs @@ -0,0 +1,130 @@ +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 delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-dingtalk] ${connected}/${configured} bots connected; retrying in ${delayMs}ms`, + ); + this.#schedule(delayMs); + return; + } + this.#retryIndex = 0; + this.#schedule(this.#healthyIntervalMs); + } catch (error) { + if (this.#closed) return; + const delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-dingtalk] connection reconciliation failed; retrying in ${delayMs}ms`, + error, + ); + this.#schedule(delayMs); + } + } +} + +export function createConnectionSupervisor(options) { + return new ConnectionSupervisor(options); +} diff --git a/plugin-src/host/channels/dingtalk/index.mjs b/plugin-src/host/channels/dingtalk/index.mjs new file mode 100644 index 0000000..d377158 --- /dev/null +++ b/plugin-src/host/channels/dingtalk/index.mjs @@ -0,0 +1,32 @@ +import { createProductionController } from './production.mjs'; +import { installDingtalkRpc } from './rpc.mjs'; + +export const name = 'dsh-dingtalk-host'; +export const inject = ['connection', 'credentials', 'webServer']; + +export async function apply(ctx, config = {}) { + if (config?.controller) return installDingtalkRpc(ctx, config.controller, config.rpcOptions); + + const production = await createProductionController(ctx, config, config.internals); + const disposeRpc = installDingtalkRpc(ctx, production.controller, config.rpcOptions); + ctx.effect(() => async () => { + await production.close(); + }, 'dsh-dingtalk: close bot connections'); + return disposeRpc; +} + +export function createDingtalkHostPlugin(config) { + return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) }); +} + +export { createConnectionSupervisor, ConnectionSupervisor } from './connection-supervisor.mjs'; +export { createProductionController } from './production.mjs'; +export { + DINGTALK_ENDPOINTS, + DINGTALK_RPC_CHANNEL, + DINGTALK_RPC_ENDPOINTS, + createDingtalkRpcHandler, + installDingtalkRpc, +} from './rpc.mjs'; +export { DingtalkController } from '../../../../src/channels/dingtalk/dingtalk-controller.mjs'; +export { DingtalkRuntime } from '../../../../src/channels/dingtalk/dingtalk-runtime.mjs'; diff --git a/plugin-src/host/channels/dingtalk/production.mjs b/plugin-src/host/channels/dingtalk/production.mjs new file mode 100644 index 0000000..9a6801e --- /dev/null +++ b/plugin-src/host/channels/dingtalk/production.mjs @@ -0,0 +1,122 @@ +import { unlink } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { DingtalkConfigStore } from '../../../../src/channels/dingtalk/config-store.mjs'; +import { DingtalkDeviceAuth } from '../../../../src/channels/dingtalk/device-auth.mjs'; +import { DingtalkController } from '../../../../src/channels/dingtalk/dingtalk-controller.mjs'; +import { DingtalkRuntime } from '../../../../src/channels/dingtalk/dingtalk-runtime.mjs'; +import { HarnessClient } from '../../../../src/channels/dingtalk/harness-client.mjs'; +import { DingtalkStateStore } from '../../../../src/channels/dingtalk/state-store.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-dingtalk 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-dingtalk')); + return { + root, + 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-dingtalk requires ctx.credentials'); + if (!ctx?.webServer) throw new TypeError('dsh-dingtalk requires ctx.webServer'); + + const ConfigStore = internals.ConfigStore ?? DingtalkConfigStore; + const DeviceAuth = internals.DeviceAuth ?? DingtalkDeviceAuth; + const StateStore = internals.StateStore ?? DingtalkStateStore; + const Harness = internals.HarnessClient ?? HarnessClient; + const Controller = internals.Controller ?? DingtalkController; + const Runtime = internals.Runtime ?? DingtalkRuntime; + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor; + const logger = typeof ctx.logger === 'function' + ? ctx.logger('dsh-dingtalk') + : (ctx.logger ?? console); + const paths = pluginPaths(config); + const configStore = await new ConfigStore(paths.config).load(); + const deviceAuth = internals.deviceAuth ?? new DeviceAuth({ + baseUrl: config.registrationBaseUrl, + }); + 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({ + deviceAuth, + credentials: ctx.credentials, + configStore, + logger, + createRuntime: async ({ botId, config: botConfig, clientSecret }) => { + const state = await stateFor(botId); + return new Runtime({ + config: botConfig, + clientSecret, + harness, + state, + replyTimeoutMs: config.replyTimeoutMs ?? 600_000, + maxMessageChars: config.maxMessageChars ?? 4_000, + connectTimeoutMs: config.connectTimeoutMs ?? 15_000, + 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') { + await state.remove(); + return; + } + 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/dingtalk/rpc.mjs b/plugin-src/host/channels/dingtalk/rpc.mjs new file mode 100644 index 0000000..ae6c241 --- /dev/null +++ b/plugin-src/host/channels/dingtalk/rpc.mjs @@ -0,0 +1,221 @@ +import QRCode from 'qrcode'; + +export const DINGTALK_RPC_CHANNEL = '/dingtalk'; +export const DINGTALK_ENDPOINTS = Object.freeze({ + status: 'connection.status', + beginProvisioning: 'provision.begin', + pollProvisioning: 'provision.poll', + cancelProvisioning: 'provision.cancel', + reconnectBot: 'bot.reconnect', + deleteBot: 'bot.delete', + approveSender: 'bot.sender.approve', + revokeSender: 'bot.sender.revoke', +}); +export const DINGTALK_RPC_ENDPOINTS = Object.freeze(Object.values(DINGTALK_ENDPOINTS)); + +const FORBIDDEN_PUBLIC_KEYS = new Set([ + 'clientSecret', + 'client_secret', + 'deviceCode', + 'device_code', + 'secretRef', + 'staffId', + 'senderStaffId', + 'verificationUrl', + 'verificationUri', + 'userCode', +]); + +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 === DINGTALK_ENDPOINTS.status) { + return exactKeys(payload, []) ? null : 'connection.status does not accept fields.'; + } + if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) { + return exactKeys(payload, ['locale']) && (payload.locale === undefined || payload.locale === 'zh-CN') + ? null + : 'provision.begin received unsupported fields.'; + } + if ([DINGTALK_ENDPOINTS.pollProvisioning, DINGTALK_ENDPOINTS.cancelProvisioning].includes(endpoint)) { + return exactKeys(payload, ['attemptId']) && validId(payload.attemptId) + ? null + : `${endpoint} requires an attemptId.`; + } + if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) { + return exactKeys(payload, ['botId']) && validId(payload.botId) + ? null + : 'bot.reconnect requires a botId.'; + } + if (endpoint === DINGTALK_ENDPOINTS.deleteBot) { + return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true + ? null + : 'bot.delete requires a botId and confirm=true.'; + } + if (endpoint === DINGTALK_ENDPOINTS.approveSender) { + return exactKeys(payload, ['botId', 'requestId', 'confirm']) + && validId(payload.botId) + && validId(payload.requestId) + && payload.confirm === true + ? null + : 'bot.sender.approve requires botId, requestId, and confirm=true.'; + } + if (endpoint === DINGTALK_ENDPOINTS.revokeSender) { + return exactKeys(payload, ['botId', 'senderKey', 'confirm']) + && validId(payload.botId) + && validId(payload.senderKey) + && payload.confirm === true + ? null + : 'bot.sender.revoke requires botId, senderKey, and confirm=true.'; + } + return 'Unknown DingTalk endpoint.'; +} + +function badRequest(message) { + return { ok: false, error: { code: 'bad-request', message } }; +} + +function cancelled() { + return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }; +} + +function internalFailure() { + return { + ok: false, + error: { code: 'dingtalk-operation-failed', message: '钉钉操作失败,请稍后重试。' }, + }; +} + +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); +} + +function assertController(controller) { + for (const method of [ + 'status', + 'startProvisioning', + 'registrationStatus', + 'cancelProvisioning', + 'reconnectBot', + 'deleteBot', + 'approveSender', + 'revokeSender', + ]) { + if (typeof controller?.[method] !== 'function') { + throw new TypeError(`A complete DingTalk controller is required (${method})`); + } + } +} + +export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { + assertController(controller); + 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 cancelled(); + if (!DINGTALK_RPC_ENDPOINTS.includes(endpoint)) return badRequest('Unknown DingTalk endpoint.'); + const invalid = payloadFailure(endpoint, payload); + if (invalid) return badRequest(invalid); + + try { + let value; + if (endpoint === DINGTALK_ENDPOINTS.status) { + value = await publicStatus(await controller.status(), cachedEncode); + } else if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) { + const started = await controller.startProvisioning({ signal }); + if (signal?.aborted) { + await controller.cancelProvisioning(started.attemptId); + return cancelled(); + } + value = await withEncodedQr(started, cachedEncode); + } else if (endpoint === DINGTALK_ENDPOINTS.pollProvisioning) { + const current = await controller.registrationStatus(payload.attemptId); + if (!current) return badRequest('The provisioning attempt no longer exists.'); + value = await withEncodedQr(current, cachedEncode); + } else if (endpoint === DINGTALK_ENDPOINTS.cancelProvisioning) { + value = await controller.cancelProvisioning(payload.attemptId); + if (!value) return badRequest('The provisioning attempt no longer exists.'); + value = sanitizePublic(value); + } else if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) { + value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode); + } else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) { + value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode); + } else if (endpoint === DINGTALK_ENDPOINTS.approveSender) { + value = await publicStatus( + await controller.approveSender(payload.botId, payload.requestId), + cachedEncode, + ); + } else { + value = await publicStatus( + await controller.revokeSender(payload.botId, payload.senderKey), + cachedEncode, + ); + } + return signal?.aborted ? cancelled() : { ok: true, value }; + } catch { + return signal?.aborted ? cancelled() : internalFailure(); + } + }; +} + +export function installDingtalkRpc(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( + DINGTALK_RPC_CHANNEL, + createDingtalkRpcHandler(controller, options), + { authority: 'loopback' }, + ); +} diff --git a/plugin-src/host/channels/feishu/connection-supervisor.mjs b/plugin-src/host/channels/feishu/connection-supervisor.mjs new file mode 100644 index 0000000..abbcc16 --- /dev/null +++ b/plugin-src/host/channels/feishu/connection-supervisor.mjs @@ -0,0 +1,167 @@ +const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]); + +function safeDelay(value, fallback) { + return Number.isFinite(value) && value >= 0 ? value : fallback; +} + +function safeRetryDelays(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS]; + const delays = value.map((delay) => safeDelay(delay, -1)).filter((delay) => delay >= 0); + return delays.length > 0 ? delays : [...DEFAULT_RETRY_DELAYS_MS]; +} + +function totals(status) { + const configured = Number.isInteger(status?.totals?.configured) + ? status.totals.configured + : (Array.isArray(status?.bots) ? status.bots.length : 0); + const connected = Number.isInteger(status?.totals?.connected) + ? status.totals.connected + : (Array.isArray(status?.bots) + ? status.bots.filter((bot) => bot?.connected === true).length + : 0); + return { configured, connected }; +} + +/** + * Starts bot connections only after the in-process Harness HTTP API is ready, + * then periodically reconciles failed/offline bots. Timers never keep the Host + * alive on their own and shutdown waits for an in-flight reconciliation. + */ +export class ConnectionSupervisor { + #controller; + #harness; + #logger; + #retryDelaysMs; + #healthyIntervalMs; + #setTimeout; + #clearTimeout; + #timer = null; + #running = null; + #retryIndex = 0; + #started = false; + #closed = 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.#retryDelaysMs = safeRetryDelays(retryDelaysMs); + this.#healthyIntervalMs = safeDelay(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(); + } catch (error) { + if (this.#closed) return; + this.#retry('Harness Host is not ready', error); + return; + } + if (this.#closed) return; + + try { + await this.#controller.initialize(); + if (this.#closed) return; + + const status = this.#controller.status(); + this.#resolveReady?.(status); + this.#resolveReady = null; + const current = totals(status); + if (current.connected < current.configured) { + const delay = this.#retryDelaysMs[Math.min(this.#retryIndex, this.#retryDelaysMs.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-feishu] ${current.connected}/${current.configured} bots connected; retrying automatically in ${delay}ms`, + ); + this.#schedule(delay); + return; + } + + this.#retryIndex = 0; + this.#schedule(this.#healthyIntervalMs); + } catch (error) { + if (this.#closed) return; + this.#retry('Bot connection reconciliation failed', error); + } + } + + #retry(message, error) { + const delay = this.#retryDelaysMs[Math.min(this.#retryIndex, this.#retryDelaysMs.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-feishu] ${message}; retrying automatically in ${delay}ms`, + error, + ); + this.#schedule(delay); + } +} + +export function createConnectionSupervisor(options) { + return new ConnectionSupervisor(options); +} diff --git a/plugin-src/host/channels/feishu/controller.mjs b/plugin-src/host/channels/feishu/controller.mjs new file mode 100644 index 0000000..41bf036 --- /dev/null +++ b/plugin-src/host/channels/feishu/controller.mjs @@ -0,0 +1,172 @@ +const ACTIVE_REGISTRATION_STATES = new Set([ + 'starting', + 'qr_ready', + 'polling', + 'slow_down', + 'domain_switched', +]); + +function credentialResult(result) { + const appId = result?.client_id ?? result?.appId; + const appSecret = result?.client_secret ?? result?.appSecret; + if (typeof appId !== 'string' || appId.length === 0 + || typeof appSecret !== 'string' || appSecret.length === 0) { + throw new TypeError('Feishu registration returned invalid credentials'); + } + return { + appId, + appSecret, + userInfo: result?.user_info ?? result?.userInfo, + }; +} + +async function readConnectionStatus(connectionManager) { + if (typeof connectionManager.status !== 'function') return {}; + return await connectionManager.status(); +} + +function isConnected(status) { + if (status?.connected === true) return true; + return status?.ready === true + && status?.feishuLongConnectionState === 'connected' + && status?.harnessReachable === true; +} + +/** + * Minimal orchestration boundary between QR provisioning and the live bot. + * + * `createProvisioningManager` receives the only callback that can observe the + * App Secret. The callback persists credentials and starts the long-lived + * connection before the provisioning manager may report `succeeded`. + */ +export class ProvisioningBackedController { + #credentialStore; + #connectionManager; + #registrationOptions; + #manager; + #knownConfigured = false; + #lastError = null; + + constructor({ + createProvisioningManager, + credentialStore, + connectionManager, + registrationOptions = {}, + } = {}) { + if (typeof createProvisioningManager !== 'function') { + throw new TypeError('createProvisioningManager is required'); + } + if (!credentialStore + || typeof credentialStore.save !== 'function' + || typeof credentialStore.clear !== 'function') { + throw new TypeError('credentialStore.save/clear are required'); + } + if (!connectionManager + || typeof connectionManager.connect !== 'function' + || typeof connectionManager.disconnect !== 'function') { + throw new TypeError('connectionManager.connect/disconnect are required'); + } + if (registrationOptions === null + || typeof registrationOptions !== 'object' + || Array.isArray(registrationOptions)) { + throw new TypeError('registrationOptions must be an object'); + } + + this.#credentialStore = credentialStore; + this.#connectionManager = connectionManager; + this.#registrationOptions = structuredClone(registrationOptions); + this.#manager = createProvisioningManager({ + onCredentials: (result) => this.#acceptCredentials(result), + }); + if (!this.#manager + || typeof this.#manager.start !== 'function' + || typeof this.#manager.status !== 'function' + || typeof this.#manager.cancel !== 'function') { + throw new TypeError('The provisioning manager must implement start/status/cancel'); + } + } + + async startRegistration() { + this.#lastError = null; + await this.#manager.start(structuredClone(this.#registrationOptions)); + return this.status(); + } + + async cancelRegistration() { + await this.#manager.cancel(); + return this.status(); + } + + async disconnect() { + await this.#manager.cancel(); + await this.#connectionManager.disconnect(); + try { + await this.#credentialStore.clear(); + this.#knownConfigured = false; + this.#lastError = null; + } catch { + // Do not reflect a credential provider's error text across the RPC + // boundary. It may include a backend path or secret reference detail. + this.#lastError = { + code: 'credential_removal_failed', + message: 'Unable to remove the Feishu credentials.', + }; + } + return this.status(); + } + + async status() { + const registration = await this.#manager.status(); + const connection = await readConnectionStatus(this.#connectionManager); + const connected = isConnected(connection); + let configured = this.#knownConfigured; + if (typeof this.#credentialStore.configured === 'function') { + try { + configured = await this.#credentialStore.configured(); + } catch { + configured = this.#knownConfigured; + } + } + + let phase = 'unconfigured'; + if (connected) phase = 'connected'; + else if (ACTIVE_REGISTRATION_STATES.has(registration?.state)) phase = 'registering'; + else if (registration?.state === 'saving') phase = 'connecting'; + else if (this.#lastError || registration?.state === 'error') phase = 'error'; + else if (configured) phase = 'disconnected'; + + return { + phase, + connected, + configured, + registration, + connection, + error: this.#lastError ?? registration?.error ?? null, + }; + } + + async close() { + await this.#manager.cancel(); + await this.#connectionManager.disconnect(); + } + + async #acceptCredentials(result) { + const credentials = credentialResult(result); + try { + await this.#credentialStore.save(credentials); + this.#knownConfigured = true; + await this.#connectionManager.connect(credentials); + this.#lastError = null; + } catch { + this.#lastError = { + code: 'connection_failed', + message: 'The bot was created, but its connection could not be started.', + }; + throw new Error('Unable to activate the Feishu connection.'); + } + } +} + +export function createProvisioningBackedController(options) { + return new ProvisioningBackedController(options); +} diff --git a/plugin-src/host/channels/feishu/credential-store.mjs b/plugin-src/host/channels/feishu/credential-store.mjs new file mode 100644 index 0000000..c7e9633 --- /dev/null +++ b/plugin-src/host/channels/feishu/credential-store.mjs @@ -0,0 +1,84 @@ +/** + * Credential references owned by the Feishu Host plugin. They deliberately + * use DSH's credential provider instead of plugin settings, so the browser's + * configuration plane can only observe configured/source metadata. + */ +export const FEISHU_APP_ID_REF = 'DSH_FEISHU_APP_ID'; +export const FEISHU_APP_SECRET_REF = 'DSH_FEISHU_APP_SECRET'; + +function assertNonEmptyString(value, label) { + if (typeof value !== 'string' || value.length === 0) { + throw new TypeError(`${label} must be a non-empty string`); + } + return value; +} + +async function restore(provider, ref, previous) { + try { + if (previous?.value) await provider.set(ref, previous.value); + else await provider.unset(ref); + } catch { + // Preserve the original write failure. The provider remains the source + // of truth and will report the partial state through describe(). + } +} + +/** + * Adapt the real DSH `ctx.credentials` seam to the small interface consumed + * by the Feishu controller. Secret values only travel Host-to-Host here. + * + * @param {{resolve(Function), describe(Function), set(Function), unset(Function)}} provider + * @param {{appIdRef?: string, appSecretRef?: string}} options + */ +export function createDshCredentialStore(provider, options = {}) { + if (!provider + || typeof provider.resolve !== 'function' + || typeof provider.describe !== 'function' + || typeof provider.set !== 'function' + || typeof provider.unset !== 'function') { + throw new TypeError('A DSH credential provider is required'); + } + + const appIdRef = options.appIdRef ?? FEISHU_APP_ID_REF; + const appSecretRef = options.appSecretRef ?? FEISHU_APP_SECRET_REF; + + return Object.freeze({ + async save({ appId, appSecret }) { + const nextId = assertNonEmptyString(appId, 'Feishu App ID'); + const nextSecret = assertNonEmptyString(appSecret, 'Feishu App Secret'); + const [previousId, previousSecret] = await Promise.all([ + provider.resolve(appIdRef), + provider.resolve(appSecretRef), + ]); + + try { + // Store the secret first. An App ID without its matching secret must + // never be treated as a usable integration. + await provider.set(appSecretRef, nextSecret); + await provider.set(appIdRef, nextId); + } catch { + await restore(provider, appSecretRef, previousSecret); + await restore(provider, appIdRef, previousId); + throw new Error('Unable to store the Feishu credentials.'); + } + }, + + async clear() { + const outcomes = await Promise.allSettled([ + provider.unset(appIdRef), + provider.unset(appSecretRef), + ]); + if (outcomes.some((outcome) => outcome.status === 'rejected')) { + throw new Error('Unable to remove the Feishu credentials.'); + } + }, + + async configured() { + const [appId, appSecret] = await Promise.all([ + provider.describe(appIdRef), + provider.describe(appSecretRef), + ]); + return appId.configured === true && appSecret.configured === true; + }, + }); +} diff --git a/plugin-src/host/channels/feishu/index.mjs b/plugin-src/host/channels/feishu/index.mjs new file mode 100644 index 0000000..4bf6919 --- /dev/null +++ b/plugin-src/host/channels/feishu/index.mjs @@ -0,0 +1,66 @@ +import { createProvisioningBackedController } from './controller.mjs'; +import { createProductionController } from './production.mjs'; +import { installFeishuRpc } from './rpc.mjs'; + +export const name = 'dsh-feishu-host'; +export const inject = ['connection', 'credentials', 'webServer']; + +function controllerFrom(ctx, config) { + if (config?.controller) return config.controller; + if (typeof config?.createController === 'function') return config.createController(); + if (typeof config?.createProvisioningManager === 'function') { + return createProvisioningBackedController(config); + } + // Cordis deliberately throws when a plugin reads an undeclared service, + // even through optional chaining. Production uses the explicit services in + // `inject`; test/programmatic controllers must therefore come from config. + return undefined; +} + +/** + * Cordis/DSH Host plugin entry. Production composition may supply an owned + * controller service; tests and embedded distributions may inject one through + * config without changing the Connection RPC boundary. + */ +export async function apply(ctx, config = {}) { + const controller = controllerFrom(ctx, config); + if (controller) return installFeishuRpc(ctx, controller); + + const production = await createProductionController(ctx, config); + const disposeRpc = installFeishuRpc(ctx, production.controller); + ctx.effect(() => async () => { + await production.close(); + }, 'dsh-feishu: close controller and live connection'); + return disposeRpc; +} + +/** Create a programmatic plugin module with dependencies closed over. */ +export function createFeishuHostPlugin(config) { + return Object.freeze({ + name, + inject, + apply: (ctx) => apply(ctx, config), + }); +} + +export { + ProvisioningBackedController, + createProvisioningBackedController, +} from './controller.mjs'; +export { createProductionController } from './production.mjs'; +export { ConnectionSupervisor, createConnectionSupervisor } from './connection-supervisor.mjs'; +export { MultiBotDshFeishuController } from '../../../../src/channels/feishu/multi-bot-controller.mjs'; +export { + FEISHU_APP_ID_REF, + FEISHU_APP_SECRET_REF, + createDshCredentialStore, +} from './credential-store.mjs'; +export { + FEISHU_ENDPOINTS, + FEISHU_MULTI_ENDPOINTS, + FEISHU_RPC_CHANNEL, + FEISHU_RPC_ENDPOINTS, + createFeishuRpcHandler, + installFeishuRpc, + toPublicFeishuStatus, +} from './rpc.mjs'; diff --git a/plugin-src/host/channels/feishu/production.mjs b/plugin-src/host/channels/feishu/production.mjs new file mode 100644 index 0000000..62adfc7 --- /dev/null +++ b/plugin-src/host/channels/feishu/production.mjs @@ -0,0 +1,138 @@ +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { unlink } from 'node:fs/promises'; +import * as Lark from '@larksuiteoapi/node-sdk'; +import { createConnectionSupervisor } from './connection-supervisor.mjs'; +import { verifyFeishuApp } from '../../../../src/channels/feishu/feishu-app.mjs'; +import { FeishuRuntime } from '../../../../src/channels/feishu/feishu-runtime.mjs'; +import { HarnessClient } from '../../../../src/channels/feishu/harness-client.mjs'; +import { + LEGACY_FEISHU_SECRET_REF, + PluginConfigStore, +} from '../../../../src/channels/feishu/plugin-config-store.mjs'; +import { MultiBotDshFeishuController } from '../../../../src/channels/feishu/multi-bot-controller.mjs'; +import { StateStore } from '../../../../src/channels/feishu/state-store.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-feishu requires an initialized DSH webServer port'); + } + // Even when DSH listens on all interfaces, its own plugin talks through the + // loopback authority accepted by Connection's request-trust fence. + 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-feishu')); + return { + root, + config: resolve(config.configPath ?? join(root, 'config.json')), + legacyState: resolve(config.statePath ?? join(root, 'state.json')), + bots: resolve(config.botsDir ?? join(root, 'bots')), + }; +} + +/** + * Assemble the production controller from DSH Host services and local plugin + * classes. No bridge process or environment App credentials are required. + */ +export async function createProductionController(ctx, config = {}, internals = {}) { + if (!ctx?.credentials) throw new TypeError('dsh-feishu requires ctx.credentials'); + if (!ctx?.webServer) throw new TypeError('dsh-feishu requires ctx.webServer'); + + const lark = internals.lark ?? Lark; + const Controller = internals.Controller ?? MultiBotDshFeishuController; + const ConfigStore = internals.ConfigStore ?? PluginConfigStore; + const SessionStateStore = internals.StateStore ?? StateStore; + const Harness = internals.HarnessClient ?? HarnessClient; + const Runtime = internals.FeishuRuntime ?? FeishuRuntime; + const verifyApp = internals.verifyFeishuApp ?? verifyFeishuApp; + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor; + const logger = typeof ctx.logger === 'function' + ? ctx.logger('dsh-feishu') + : (ctx.logger ?? console); + const paths = pluginPaths(config); + const configStore = await new ConfigStore(paths.config).load(); + // State is lazy per bot. A corrupt legacy file can therefore fail only the + // migrated bot and cannot prevent healthy v2 bots from starting. + const stateStores = new Map(); + const statePathFor = (botConfig) => !botConfig.id + || !botConfig.secretRef + || botConfig.secretRef === LEGACY_FEISHU_SECRET_REF + ? paths.legacyState + : resolve(paths.bots, botConfig.id, 'state.json'); + const stateFor = async (botConfig) => { + const stateKey = botConfig.id ?? '__legacy__'; + let state = stateStores.get(stateKey); + if (!state) { + state = await new SessionStateStore(statePathFor(botConfig)).load(); + stateStores.set(stateKey, state); + } + return state; + }; + const harness = new Harness({ + baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), + workspace: resolve(config.workspace ?? process.cwd()), + agentPreset: config.agentPreset ?? 'standard', + // This plugin is already hosted by a running DSH process. Starting a + // second DSH would create a competing server and lifecycle. + autostart: false, + dshBin: config.dshBin ?? 'dsh', + }); + + const controller = new Controller({ + registerApp: (options) => lark.registerApp(options), + verifyApp, + credentials: ctx.credentials, + configStore, + createRuntime: async ({ botId, config: botConfig, appSecret }) => { + const state = await stateFor(botConfig); + return new Runtime({ + lark, + appId: botConfig.appId, + appSecret, + domain: botConfig.domain, + ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId], + harness, + state, + replyTimeoutMs: config.replyTimeoutMs ?? 600_000, + logger: { + error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args), + warn: (...args) => logger.warn?.(`[${botId ?? botConfig.id}]`, ...args), + info: (...args) => logger.info?.(`[${botId ?? botConfig.id}]`, ...args), + debug: (...args) => logger.debug?.(`[${botId ?? botConfig.id}]`, ...args), + }, + }); + }, + deleteState: async ({ botId, config: botConfig }) => { + stateStores.delete(botId); + try { + await unlink(statePathFor(botConfig)); + } 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/feishu/rpc.mjs b/plugin-src/host/channels/feishu/rpc.mjs new file mode 100644 index 0000000..46bd312 --- /dev/null +++ b/plugin-src/host/channels/feishu/rpc.mjs @@ -0,0 +1,434 @@ +import QRCode from 'qrcode'; +import { + FEISHU_ENDPOINTS, + FEISHU_RPC_CHANNEL, +} from '../../../client/channels/feishu/api.js'; + +export { FEISHU_ENDPOINTS, FEISHU_RPC_CHANNEL }; +export const FEISHU_MULTI_ENDPOINTS = Object.freeze({ + reconnectBot: 'bot.reconnect', + disconnectBot: 'bot.disconnect', + deleteBot: 'bot.delete', +}); +export const FEISHU_RPC_ENDPOINTS = Object.freeze([ + ...new Set([...Object.values(FEISHU_ENDPOINTS), ...Object.values(FEISHU_MULTI_ENDPOINTS)]), +]); + +const REGISTRATION_STATES = new Set([ + 'idle', 'starting', 'qr_ready', 'polling', 'slow_down', + 'domain_switched', 'saving', 'succeeded', 'expired', 'cancelled', 'error', +]); +const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/; + +const PUBLIC_ERROR_MESSAGES = Object.freeze({ + abort: 'Registration was cancelled.', + access_denied: 'Registration was denied.', + expired_token: 'The registration QR code expired.', + invalid_credentials: 'Feishu returned invalid app credentials.', + credentials_callback_failed: 'Unable to activate the Feishu connection.', + registration_failed: 'Unable to register the Feishu app.', + connection_failed: 'The bot was created, but its connection could not be started.', + credential_removal_failed: 'Unable to remove the Feishu credentials.', + state_cleanup_failed: 'Unable to remove the bot session data. Please retry.', + deletion_pending: 'Bot deletion is incomplete. Retry removal to finish cleanup.', + missing_credentials: 'The bot credentials are missing. Delete it and scan again.', +}); + +const POLL_STATUS_BY_REGISTRATION = Object.freeze({ + idle: 'pending', starting: 'pending', qr_ready: 'pending', polling: 'pending', + slow_down: 'pending', domain_switched: 'pending', saving: 'connecting', + succeeded: 'connected', expired: 'expired', cancelled: 'failed', error: 'failed', +}); + +function isPlainObject(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasOnlyKeys(value, allowed) { + return isPlainObject(value) + && Reflect.ownKeys(value).every((key) => typeof key === 'string' && allowed.has(key)); +} + +function finiteNumber(value) { + return Number.isFinite(value) ? value : undefined; +} + +function safeOpaqueId(value) { + return typeof value === 'string' && SAFE_ID.test(value); +} + +function publicError(error) { + if (!error || typeof error !== 'object') return null; + const code = typeof error.code === 'string' && Object.hasOwn(PUBLIC_ERROR_MESSAGES, error.code) + ? error.code + : 'registration_failed'; + return { code, message: PUBLIC_ERROR_MESSAGES[code] }; +} + +function publicRegistration(registration) { + if (!registration || typeof registration !== 'object') return { state: 'idle', attempt: 0 }; + const state = REGISTRATION_STATES.has(registration.state) ? registration.state : 'error'; + const attempt = safeOpaqueId(registration.attempt) + ? registration.attempt + : (finiteNumber(registration.attempt) ?? 0); + const result = { state, attempt }; + const updatedAt = finiteNumber(registration.updatedAt); + const expiresAt = finiteNumber(registration.expiresAt); + const remainingSeconds = finiteNumber(registration.remainingSeconds); + const pollIntervalSeconds = finiteNumber(registration.pollIntervalSeconds); + if (updatedAt !== undefined) result.updatedAt = updatedAt; + if (typeof registration.qrCodeUrl === 'string' && registration.qrCodeUrl.length > 0) { + result.qrCodeUrl = registration.qrCodeUrl; + } + if (expiresAt !== undefined) result.expiresAt = expiresAt; + if (remainingSeconds !== undefined) result.remainingSeconds = remainingSeconds; + if (pollIntervalSeconds !== undefined) result.pollIntervalSeconds = pollIntervalSeconds; + if (safeOpaqueId(registration.botId)) result.botId = registration.botId; + const error = publicError(registration.error); + if (error) result.error = error; + return result; +} + +function connectionFacts(connection) { + const source = connection && typeof connection === 'object' ? connection : {}; + const connected = source.connected === true + || (source.ready === true + && source.feishuLongConnectionState === 'connected' + && source.harnessReachable === true); + return { + connected, + ready: source.ready === true, + harnessReachable: source.harnessReachable === true, + }; +} + +function publicBot(bot) { + const source = bot && typeof bot === 'object' ? bot : {}; + const result = { + name: typeof source.name === 'string' && source.name.length > 0 ? source.name : '飞书机器人', + }; + if (typeof source.avatarUrl === 'string') result.avatarUrl = source.avatarUrl; + if (typeof source.appIdMasked === 'string') result.appIdMasked = source.appIdMasked; + if (typeof source.tenantName === 'string') result.tenantName = source.tenantName; + if (source.domain === 'feishu' || source.domain === 'lark') result.domain = source.domain; + if (typeof source.activated === 'boolean' || typeof source.activated === 'number') { + result.activated = source.activated; + } + return result; +} + +function publicHealth(status, connected) { + if (connected) return { status: 'healthy', summary: '长连接运行正常', lastCheckedAt: Date.now() }; + if (status?.configured === true) { + return { status: 'offline', summary: '机器人尚未连接', lastCheckedAt: Date.now() }; + } + return { status: 'offline', summary: '尚未接入飞书机器人', lastCheckedAt: Date.now() }; +} + +function connectionState(status, registration, connected) { + if (connected) return 'connected'; + if (status?.phase === 'error' || registration.state === 'error') return 'error'; + if (status?.phase === 'connecting' || registration.state === 'saving') return 'connecting'; + if (status?.phase === 'registering' + || ['starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched'].includes(registration.state)) { + return 'provisioning'; + } + return 'disconnected'; +} + +async function qrCodeDataUrl(verificationUrl) { + return QRCode.toDataURL(verificationUrl, { + errorCorrectionLevel: 'M', margin: 1, width: 320, type: 'image/png', + }); +} + +async function publicProvisioning(registration, encodeQr) { + if (!registration.qrCodeUrl) return undefined; + return { + attemptId: String(registration.attempt), + verificationUrl: registration.qrCodeUrl, + qrCodeDataUrl: await encodeQr(registration.qrCodeUrl), + expiresAt: registration.expiresAt ?? Date.now() + (5 * 60_000), + pollIntervalMs: Math.max(800, Math.min(10_000, (registration.pollIntervalSeconds ?? 1.8) * 1000)), + }; +} + +function publicBotEntry(entry) { + const source = entry && typeof entry === 'object' ? entry : {}; + if (!safeOpaqueId(source.botId)) return null; + const facts = connectionFacts(source.connection); + const connected = source.connected === true || facts.connected; + const registration = { state: 'idle' }; + const result = { + botId: source.botId, + state: connectionState(source, registration, connected), + connected, + configured: source.configured === true, + bot: publicBot(source.bot), + health: publicHealth(source, connected), + }; + const error = publicError(source.error); + if (error) result.error = error; + return result; +} + +/** Exact redacted browser contract consumed by the Feishu settings client. */ +export async function toPublicFeishuStatus(status, { encodeQr = qrCodeDataUrl } = {}) { + const source = status && typeof status === 'object' ? status : {}; + const registration = publicRegistration(source.registration); + const facts = connectionFacts(source.connection); + const connected = source.connected === true || facts.connected; + const provisioning = await publicProvisioning(registration, encodeQr); + const error = publicError(source.error) ?? registration.error ?? null; + const bots = Array.isArray(source.bots) + ? source.bots.map(publicBotEntry).filter(Boolean) + : []; + const snapshot = { + schemaVersion: source.schemaVersion === 2 ? 2 : 1, + revision: Number.isSafeInteger(source.revision) && source.revision >= 0 ? source.revision : 0, + state: connectionState(source, registration, connected), + connected, + configured: source.configured === true, + bot: publicBot(source.bot), + health: publicHealth(source, connected), + bots, + totals: { + configured: bots.length || (source.configured === true ? 1 : 0), + connected: bots.length ? bots.filter((bot) => bot.connected).length : (connected ? 1 : 0), + }, + }; + if (provisioning) snapshot.provisioning = provisioning; + if (error) snapshot.error = error; + return snapshot; +} + +function badRequest(message) { + return { ok: false, error: { code: 'bad-request', message, details: { issues: [] } } }; +} + +function cancelled() { + return { ok: false, error: { code: 'cancelled', message: 'The Feishu request was cancelled.', details: {} } }; +} + +function internalFailure() { + return { ok: false, error: { code: 'internal', message: 'The Feishu integration operation failed.', details: {} } }; +} + +function validPayload(endpoint, payload) { + if (endpoint === FEISHU_ENDPOINTS.status) { + return hasOnlyKeys(payload, new Set()) ? null : 'This endpoint accepts an empty payload only.'; + } + if (endpoint === FEISHU_ENDPOINTS.testConnection) { + return hasOnlyKeys(payload, new Set()) ? null : 'This endpoint accepts an empty payload only.'; + } + if (endpoint === FEISHU_ENDPOINTS.beginProvisioning) { + if (!hasOnlyKeys(payload, new Set(['locale', 'replaceAttemptId']))) { + return 'Provisioning accepts locale and replaceAttemptId only.'; + } + if (payload.locale !== undefined && payload.locale !== 'zh-CN') return 'The provisioning locale must be zh-CN.'; + if (payload.replaceAttemptId !== undefined && !safeOpaqueId(payload.replaceAttemptId)) { + return 'replaceAttemptId must be a valid opaque id.'; + } + return null; + } + if (endpoint === FEISHU_ENDPOINTS.pollProvisioning + || endpoint === FEISHU_ENDPOINTS.cancelProvisioning) { + return hasOnlyKeys(payload, new Set(['attemptId'])) && safeOpaqueId(payload.attemptId) + ? null + : 'A single valid attemptId is required.'; + } + if (endpoint === FEISHU_ENDPOINTS.disconnect) { + return hasOnlyKeys(payload, new Set(['removeCredentials'])) && payload.removeCredentials === true + ? null + : 'Disconnect requires removeCredentials=true.'; + } + if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot + || endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) { + return hasOnlyKeys(payload, new Set(['botId'])) && safeOpaqueId(payload.botId) + ? null + : 'A single valid botId is required.'; + } + if (endpoint === FEISHU_MULTI_ENDPOINTS.deleteBot) { + return hasOnlyKeys(payload, new Set(['botId', 'confirm'])) + && safeOpaqueId(payload.botId) && payload.confirm === true + ? null + : 'Deleting a bot requires a valid botId and confirm=true.'; + } + return 'Unknown Feishu endpoint.'; +} + +function abortableDelay(milliseconds, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error('aborted')); + return; + } + const timer = setTimeout(done, milliseconds); + timer.unref?.(); + function done() { + signal?.removeEventListener('abort', aborted); + resolve(); + } + function aborted() { + clearTimeout(timer); + reject(signal.reason ?? new Error('aborted')); + } + signal?.addEventListener('abort', aborted, { once: true }); + }); +} + +async function statusForRegistration(controller, attemptId) { + if (typeof controller.registrationStatus === 'function') { + return controller.registrationStatus(attemptId); + } + return controller.status(); +} + +async function waitForQr(controller, initial, attemptId, signal) { + let current = initial; + const deadline = Date.now() + 15_000; + for (;;) { + const registration = publicRegistration(current?.registration); + if (registration.qrCodeUrl) return current; + if (['error', 'expired', 'cancelled'].includes(registration.state)) { + throw new Error('Provisioning stopped before the QR code was ready.'); + } + if (Date.now() >= deadline) throw new Error('Provisioning QR code timed out.'); + await abortableDelay(50, signal); + current = await statusForRegistration(controller, attemptId); + if (!current) throw new Error('The provisioning attempt is no longer active.'); + } +} + +function sameAttempt(status, attemptId) { + return String(publicRegistration(status?.registration).attempt) === attemptId; +} + +function pollStatus(status) { + const registration = publicRegistration(status?.registration); + if (registration.state === 'succeeded') { + const connected = registration.botId + && (status?.connected === true || connectionFacts(status?.connection).connected); + return connected ? 'connected' : 'connecting'; + } + return POLL_STATUS_BY_REGISTRATION[registration.state] ?? 'failed'; +} + +function assertController(controller) { + if (!controller + || typeof controller.status !== 'function' + || typeof controller.startRegistration !== 'function' + || typeof controller.cancelRegistration !== 'function' + || typeof controller.disconnect !== 'function') { + throw new TypeError('A Feishu controller with status/start/cancel/disconnect is required'); + } +} + +/** DSH rc.6 handler: (endpoint, payload, signal) => Promise. */ +export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl } = {}) { + assertController(controller); + const qrCache = new Map(); + const attemptQr = new Map(); + const cachedEncodeQr = (url) => { + let encoded = qrCache.get(url); + if (!encoded) { + if (qrCache.size >= 32) 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 cancelled(); + if (!FEISHU_RPC_ENDPOINTS.includes(endpoint)) return badRequest('Unknown Feishu endpoint.'); + const payloadFailure = validPayload(endpoint, payload); + if (payloadFailure) return badRequest(payloadFailure); + + try { + let value; + if (endpoint === FEISHU_ENDPOINTS.status) { + value = await toPublicFeishuStatus(await controller.status(), { encodeQr: cachedEncodeQr }); + } else if (endpoint === FEISHU_ENDPOINTS.beginProvisioning) { + if (payload.replaceAttemptId) { + await controller.cancelRegistration(payload.replaceAttemptId); + } + const started = await controller.startRegistration({ locale: payload.locale }); + const attemptId = String(publicRegistration(started?.registration).attempt); + const ready = await waitForQr(controller, started, attemptId, signal); + value = (await toPublicFeishuStatus(ready, { encodeQr: cachedEncodeQr })).provisioning; + if (!value) throw new Error('Provisioning did not produce a QR code.'); + attemptQr.set(attemptId, value.verificationUrl); + } else if (endpoint === FEISHU_ENDPOINTS.pollProvisioning) { + const current = await statusForRegistration(controller, payload.attemptId); + if (!current || !sameAttempt(current, payload.attemptId)) { + return badRequest('The provisioning attempt is no longer active.'); + } + const registration = publicRegistration(current.registration); + const connection = await toPublicFeishuStatus(current, { encodeQr: cachedEncodeQr }); + value = { + status: pollStatus(current), + ...(registration.botId ? { botId: registration.botId } : {}), + ...(connection.provisioning ? { provisioning: connection.provisioning } : {}), + ...(registration.botId && connection.connected ? { connection } : {}), + ...(connection.error ? { message: connection.error.message } : {}), + }; + if (['connected', 'expired', 'failed'].includes(value.status)) { + const url = attemptQr.get(payload.attemptId); + if (url) qrCache.delete(url); + attemptQr.delete(payload.attemptId); + } + } else if (endpoint === FEISHU_ENDPOINTS.cancelProvisioning) { + const current = await statusForRegistration(controller, payload.attemptId); + if (!current || !sameAttempt(current, payload.attemptId)) { + return badRequest('The provisioning attempt is no longer active.'); + } + const multi = typeof controller.registrationStatus === 'function'; + const registration = publicRegistration(current.registration); + if (!multi && registration.state === 'saving') await controller.disconnect(); + else await controller.cancelRegistration(payload.attemptId); + const url = attemptQr.get(payload.attemptId); + if (url) qrCache.delete(url); + attemptQr.delete(payload.attemptId); + value = { status: 'failed', message: 'Registration was cancelled.' }; + } else if (endpoint === FEISHU_ENDPOINTS.testConnection) { + const current = await controller.status(); + const alreadyConnected = current?.connected === true + || connectionFacts(current?.connection).connected; + const checked = alreadyConnected || typeof controller.reconnect !== 'function' + ? current + : await controller.reconnect(); + value = await toPublicFeishuStatus(checked, { encodeQr: cachedEncodeQr }); + } else if (endpoint === FEISHU_ENDPOINTS.disconnect) { + value = await toPublicFeishuStatus(await controller.disconnect(), { encodeQr: cachedEncodeQr }); + } else if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot) { + if (typeof controller.reconnectBot !== 'function') throw new Error('Multi-bot reconnect is unavailable'); + value = await toPublicFeishuStatus(await controller.reconnectBot(payload.botId), { encodeQr: cachedEncodeQr }); + } else if (endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) { + if (typeof controller.disconnectBot !== 'function') throw new Error('Multi-bot disconnect is unavailable'); + value = await toPublicFeishuStatus(await controller.disconnectBot(payload.botId), { encodeQr: cachedEncodeQr }); + } else { + if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable'); + value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr }); + } + if (signal?.aborted) return cancelled(); + return { ok: true, value }; + } catch { + return signal?.aborted ? cancelled() : internalFailure(); + } + }; +} + +/** Register the loopback-only `/feishu` logical channel. */ +export function installFeishuRpc(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( + FEISHU_RPC_CHANNEL, + createFeishuRpcHandler(controller, options), + { authority: 'loopback' }, + ); +} diff --git a/plugin-src/host/channels/weixin/connection-supervisor.mjs b/plugin-src/host/channels/weixin/connection-supervisor.mjs new file mode 100644 index 0000000..3e5bba4 --- /dev/null +++ b/plugin-src/host/channels/weixin/connection-supervisor.mjs @@ -0,0 +1,127 @@ +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 delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-weixin] ${connected}/${configured} accounts connected; retrying in ${delayMs}ms`, + ); + this.#schedule(delayMs); + return; + } + this.#retryIndex = 0; + this.#schedule(this.#healthyIntervalMs); + } catch (error) { + if (this.#closed) return; + const delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.(`[dsh-weixin] connection reconciliation failed; retrying in ${delayMs}ms`, error); + this.#schedule(delayMs); + } + } +} + +export function createConnectionSupervisor(options) { + return new ConnectionSupervisor(options); +} diff --git a/plugin-src/host/channels/weixin/index.mjs b/plugin-src/host/channels/weixin/index.mjs new file mode 100644 index 0000000..45b28a8 --- /dev/null +++ b/plugin-src/host/channels/weixin/index.mjs @@ -0,0 +1,32 @@ +import { createProductionController } from './production.mjs'; +import { installWeixinRpc } from './rpc.mjs'; + +export const name = 'dsh-weixin-host'; +export const inject = ['connection', 'credentials', 'webServer']; + +export async function apply(ctx, config = {}) { + if (config?.controller) return installWeixinRpc(ctx, config.controller, config.rpcOptions); + + const production = await createProductionController(ctx, config, config.internals); + const disposeRpc = installWeixinRpc(ctx, production.controller, config.rpcOptions); + ctx.effect(() => async () => { + await production.close(); + }, 'dsh-weixin: close account connections'); + return disposeRpc; +} + +export function createWeixinHostPlugin(config) { + return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) }); +} + +export { createConnectionSupervisor, ConnectionSupervisor } from './connection-supervisor.mjs'; +export { createProductionController } from './production.mjs'; +export { + WEIXIN_ENDPOINTS, + WEIXIN_RPC_CHANNEL, + WEIXIN_RPC_ENDPOINTS, + createWeixinRpcHandler, + installWeixinRpc, +} from './rpc.mjs'; +export { WeixinController } from '../../../../src/channels/weixin/weixin-controller.mjs'; +export { WeixinRuntime } from '../../../../src/channels/weixin/weixin-runtime.mjs'; diff --git a/plugin-src/host/channels/weixin/production.mjs b/plugin-src/host/channels/weixin/production.mjs new file mode 100644 index 0000000..c03545a --- /dev/null +++ b/plugin-src/host/channels/weixin/production.mjs @@ -0,0 +1,119 @@ +import { unlink } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { WeixinConfigStore } from '../../../../src/channels/weixin/config-store.mjs'; +import { HarnessClient } from '../../../../src/channels/weixin/harness-client.mjs'; +import { WeixinStateStore } from '../../../../src/channels/weixin/state-store.mjs'; +import { createWeixinApi } from '../../../../src/channels/weixin/weixin-api.mjs'; +import { WeixinController } from '../../../../src/channels/weixin/weixin-controller.mjs'; +import { WeixinRuntime } from '../../../../src/channels/weixin/weixin-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-weixin 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-weixin')); + return { + root, + config: resolve(config.configPath ?? join(root, 'config.json')), + accounts: resolve(config.accountsDir ?? join(root, 'accounts')), + }; +} + +export async function createProductionController(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; + const StateStore = internals.StateStore ?? WeixinStateStore; + const Harness = internals.HarnessClient ?? HarnessClient; + const Controller = internals.Controller ?? WeixinController; + const Runtime = internals.Runtime ?? WeixinRuntime; + const api = internals.api ?? createWeixinApi(); + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor; + const logger = typeof ctx.logger === 'function' + ? ctx.logger('dsh-weixin') + : (ctx.logger ?? console); + const paths = pluginPaths(config); + const configStore = await new ConfigStore(paths.config).load(); + const stateStores = new Map(); + + const statePath = (botId) => resolve(paths.accounts, 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({ + api, + credentials: ctx.credentials, + configStore, + logger, + createRuntime: async ({ botId, config: accountConfig, token }) => { + const state = await stateFor(botId); + return new Runtime({ + api, + config: accountConfig, + token, + harness, + state, + replyTimeoutMs: config.replyTimeoutMs ?? 600_000, + maxMessageChars: config.maxMessageChars ?? 4_000, + 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') { + await state.remove(); + return; + } + 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/weixin/rpc.mjs b/plugin-src/host/channels/weixin/rpc.mjs new file mode 100644 index 0000000..c783bc8 --- /dev/null +++ b/plugin-src/host/channels/weixin/rpc.mjs @@ -0,0 +1,177 @@ +import QRCode from 'qrcode'; + +export const WEIXIN_RPC_CHANNEL = '/weixin'; +export const WEIXIN_ENDPOINTS = Object.freeze({ + status: 'connection.status', + beginProvisioning: 'provision.begin', + pollProvisioning: 'provision.poll', + submitVerification: 'provision.verify', + cancelProvisioning: 'provision.cancel', + reconnectBot: 'bot.reconnect', + deleteBot: 'bot.delete', +}); +export const WEIXIN_RPC_ENDPOINTS = Object.freeze(Object.values(WEIXIN_ENDPOINTS)); + +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 === WEIXIN_ENDPOINTS.status) { + return exactKeys(payload, []) ? null : 'connection.status does not accept fields.'; + } + if (endpoint === WEIXIN_ENDPOINTS.beginProvisioning) { + return exactKeys(payload, ['locale']) && (payload.locale === undefined || payload.locale === 'zh-CN') + ? null + : 'provision.begin received unsupported fields.'; + } + if ([WEIXIN_ENDPOINTS.pollProvisioning, WEIXIN_ENDPOINTS.cancelProvisioning].includes(endpoint)) { + return exactKeys(payload, ['attemptId']) && validId(payload.attemptId) + ? null + : `${endpoint} requires an attemptId.`; + } + if (endpoint === WEIXIN_ENDPOINTS.submitVerification) { + return exactKeys(payload, ['attemptId', 'verifyCode']) + && validId(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 exactKeys(payload, ['botId']) && validId(payload.botId) + ? null + : 'bot.reconnect requires a botId.'; + } + if (endpoint === WEIXIN_ENDPOINTS.deleteBot) { + return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true + ? null + : 'bot.delete requires a botId and confirm=true.'; + } + return 'Unknown Weixin endpoint.'; +} + +function badRequest(message) { + return { ok: false, error: { code: 'bad-request', message } }; +} + +function cancelled() { + return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }; +} + +function internalFailure() { + return { + ok: false, + error: { code: 'weixin-operation-failed', message: '微信操作失败,请稍后重试。' }, + }; +} + +async function qrDataUrl(value) { + return QRCode.toDataURL(value, { + type: 'image/png', + errorCorrectionLevel: 'M', + margin: 2, + width: 320, + }); +} + +async function withEncodedQr(value, encodeQr) { + if (!value || !value.verificationUrl) return value; + return { + ...value, + qrCodeDataUrl: await encodeQr(value.verificationUrl), + }; +} + +async function publicStatus(status, encodeQr) { + const safe = structuredClone(status); + if (safe.provisioning) safe.provisioning = await withEncodedQr(safe.provisioning, encodeQr); + return safe; +} + +function assertController(controller) { + if (!controller + || typeof controller.status !== 'function' + || typeof controller.startProvisioning !== 'function' + || typeof controller.registrationStatus !== 'function' + || typeof controller.submitVerification !== 'function' + || typeof controller.cancelProvisioning !== 'function' + || typeof controller.reconnectBot !== 'function' + || typeof controller.deleteBot !== 'function') { + throw new TypeError('A complete Weixin controller is required'); + } +} + +export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { + assertController(controller); + 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 cancelled(); + if (!WEIXIN_RPC_ENDPOINTS.includes(endpoint)) return badRequest('Unknown Weixin endpoint.'); + const invalid = payloadFailure(endpoint, payload); + if (invalid) return badRequest(invalid); + + try { + let value; + if (endpoint === WEIXIN_ENDPOINTS.status) { + value = await publicStatus(await controller.status(), cachedEncode); + } else if (endpoint === WEIXIN_ENDPOINTS.beginProvisioning) { + const started = await controller.startProvisioning(); + if (signal?.aborted) { + await controller.cancelProvisioning(started.attemptId); + return cancelled(); + } + value = await withEncodedQr(started, cachedEncode); + } else if (endpoint === WEIXIN_ENDPOINTS.pollProvisioning) { + const current = await controller.registrationStatus(payload.attemptId); + if (!current) return badRequest('The provisioning attempt no longer exists.'); + value = await withEncodedQr(current, cachedEncode); + } else if (endpoint === WEIXIN_ENDPOINTS.submitVerification) { + value = await withEncodedQr( + await controller.submitVerification(payload.attemptId, payload.verifyCode), + cachedEncode, + ); + } else if (endpoint === WEIXIN_ENDPOINTS.cancelProvisioning) { + value = await controller.cancelProvisioning(payload.attemptId); + if (!value) return badRequest('The provisioning attempt no longer exists.'); + } else if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) { + value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode); + } else { + value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode); + } + return signal?.aborted ? cancelled() : { ok: true, value }; + } catch { + return signal?.aborted ? cancelled() : internalFailure(); + } + }; +} + +export function installWeixinRpc(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( + WEIXIN_RPC_CHANNEL, + createWeixinRpcHandler(controller, options), + { authority: 'loopback' }, + ); +} diff --git a/plugin-src/host/index.mjs b/plugin-src/host/index.mjs index e23f2da..3436fb0 100644 --- a/plugin-src/host/index.mjs +++ b/plugin-src/host/index.mjs @@ -1,6 +1,6 @@ -import { apply as applyDingtalk } from '@xmanrui/dsh-dingtalk'; -import { apply as applyFeishu } from '@xmanrui/dsh-feishu'; -import { apply as applyWeixin } from '@xmanrui/dsh-weixin'; +import { apply as applyDingtalk } from './channels/dingtalk/index.mjs'; +import { apply as applyFeishu } from './channels/feishu/index.mjs'; +import { apply as applyWeixin } from './channels/weixin/index.mjs'; export const name = 'dsh-im-host'; export const inject = ['connection', 'credentials', 'webServer']; diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 82f015d..8ac21b8 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -9,14 +9,24 @@ const required = [ 'cordis.patch.yml', 'README.md', 'THIRD_PARTY_NOTICES.md', + 'plugin-src/client/channels/dingtalk/index.js', + 'plugin-src/host/channels/feishu/index.mjs', + 'plugin-src/host/channels/weixin/index.mjs', + 'plugin-src/host/channels/dingtalk/index.mjs', + 'src/channels/feishu/feishu-runtime.mjs', + 'src/channels/weixin/weixin-runtime.mjs', + 'src/channels/dingtalk/dingtalk-runtime.mjs', ]; await Promise.all(required.map((path) => access(resolve(root, path)))); -const [client, host, patch, manifestText, executable] = await Promise.all([ +const [client, host, patch, manifestText, lockText, hostSource, clientSource, executable] = await Promise.all([ readFile(resolve(root, 'lib/client.js'), 'utf8'), readFile(resolve(root, 'lib/index.js'), 'utf8'), readFile(resolve(root, 'cordis.patch.yml'), 'utf8'), readFile(resolve(root, 'package.json'), 'utf8'), + readFile(resolve(root, 'package-lock.json'), 'utf8'), + readFile(resolve(root, 'plugin-src/host/index.mjs'), 'utf8'), + readFile(resolve(root, 'plugin-src/client/index.js'), 'utf8'), stat(resolve(root, 'bin/dsh-im.mjs')), ]); const manifest = JSON.parse(manifestText); @@ -37,19 +47,35 @@ 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'); } -if (!host.includes('@xmanrui/dsh-feishu') - || !host.includes('@xmanrui/dsh-weixin') - || !host.includes('@xmanrui/dsh-dingtalk')) { - throw new Error('host bundle does not compose all three channel providers'); +for (const marker of ['/feishu', '/weixin', '/dingtalk']) { + if (!host.includes(marker)) { + throw new Error(`host bundle does not contain the internal ${marker} RPC provider`); + } +} +if (/@xmanrui\/dsh-(?:feishu|weixin|dingtalk)/.test(host)) { + throw new Error('host bundle still imports an external channel plugin'); +} +if (/@xmanrui\/dsh-(?:feishu|weixin|dingtalk)/.test( + manifestText + lockText + hostSource + clientSource, +)) { + throw new Error('source or package metadata still depends on an external channel plugin'); } if (!patch.includes("name: '@xmanrui/dsh-im'") || /dsh-(?:feishu|weixin|dingtalk)/.test(patch)) { throw new Error('bundle patch must activate only dsh-im'); } for (const name of ['@xmanrui/dsh-feishu', '@xmanrui/dsh-weixin', '@xmanrui/dsh-dingtalk']) { - const spec = manifest.dependencies?.[name]; - if (typeof spec !== 'string' - || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/archive\/[0-9a-f]{40}\.tar\.gz$/.test(spec)) { - throw new Error(`${name} must use a pinned public HTTPS archive`); + if (manifest.dependencies?.[name]) { + throw new Error(`${name} must not remain an external dependency`); + } +} +const directDependencies = { + '@larksuiteoapi/node-sdk': '1.73.0', + 'dingtalk-stream': '2.1.4', + qrcode: '1.5.4', +}; +for (const [name, version] of Object.entries(directDependencies)) { + if (manifest.dependencies?.[name] !== version) { + throw new Error(`${name} must be a pinned direct dependency at ${version}`); } } if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable'); diff --git a/src/channels/dingtalk/config-store.mjs b/src/channels/dingtalk/config-store.mjs new file mode 100644 index 0000000..9459c41 --- /dev/null +++ b/src/channels/dingtalk/config-store.mjs @@ -0,0 +1,282 @@ +import { createHash, randomUUID } 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([]) }); +const STORED_BOT_KEYS = new Set(['clientId', 'secretRef', 'approvedSenders']); + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function digest(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function safeBotId(value) { + const id = cleanString(value); + return id && /^dt_[a-f0-9]{24}$/.test(id) ? id : null; +} + +function safeSecretRef(value) { + const ref = cleanString(value); + return ref && /^DSH_DINGTALK_BOT_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null; +} + +function safeSenderKey(value) { + const key = cleanString(value); + return key && /^dt_sender_[a-f0-9]{32}$/.test(key) ? key : null; +} + +function normalizeApprovedSender(value) { + const record = typeof value === 'string' ? { staffId: value } : value; + if (!record || typeof record !== 'object' || Array.isArray(record)) return null; + const senderKey = safeSenderKey(record.senderKey); + const staffId = cleanString(record.staffId); + if (!senderKey || !staffId) return null; + return Object.freeze({ + senderKey, + staffId, + displayName: cleanString(record.displayName), + approvedAt: cleanString(record.approvedAt), + }); +} + +function normalizeApprovedSenders(value) { + if (!Array.isArray(value)) return null; + const senders = value.map(normalizeApprovedSender); + if (senders.some((sender) => sender === null)) return null; + const ids = new Set(); + const keys = new Set(); + for (const sender of senders) { + if (ids.has(sender.staffId) || keys.has(sender.senderKey)) return null; + ids.add(sender.staffId); + keys.add(sender.senderKey); + } + return Object.freeze(senders); +} + +/** + * Derives stable non-secret identifiers for a DingTalk bot credential. + * @param {string} clientId DingTalk application client ID. + * @returns {{botId: string, secretRef: string}} Derived identifiers. + */ +export function deriveDingtalkBotIdentity(clientId) { + const value = cleanString(clientId); + if (!value) throw new TypeError('clientId is required'); + const valueDigest = digest(value).slice(0, 24); + return Object.freeze({ + botId: `dt_${valueDigest}`, + secretRef: `DSH_DINGTALK_BOT_SECRET_${valueDigest.toUpperCase()}`, + }); +} + +/** + * Creates a random browser-safe key for an approved DingTalk sender. + * @returns {string} Opaque sender key. + */ +export function deriveDingtalkSenderKey() { + return `dt_sender_${randomUUID().replaceAll('-', '')}`; +} + +/** + * Redacts a DingTalk sender ID for display. + * @param {string} staffId DingTalk staff ID. + * @returns {string} Partially redacted identifier. + */ +export function maskDingtalkSenderId(staffId) { + const value = cleanString(staffId); + if (!value) return '钉钉用户'; + return '身份已隐藏'; +} + +/** + * Redacts a DingTalk client ID for display. + * @param {string} clientId DingTalk application client ID. + * @returns {string} Partially redacted client ID. + */ +export function maskDingtalkClientId(clientId) { + const value = cleanString(clientId); + if (!value) return '钉钉机器人'; + if (value.length <= 8) return `${value.slice(0, 2)}••••`; + return `${value.slice(0, 4)}••••${value.slice(-4)}`; +} + +function normalizeBot(value, { stored = false } = {}) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + if ('clientSecret' in value || 'client_secret' in value || 'deviceCode' in value) return null; + if (stored && Object.keys(value).some((key) => !STORED_BOT_KEYS.has(key))) return null; + const clientId = cleanString(value.clientId); + const secretRef = safeSecretRef(value.secretRef); + const approvedSenders = normalizeApprovedSenders(value.approvedSenders ?? []); + if (!clientId || !secretRef || !approvedSenders) return null; + const identity = deriveDingtalkBotIdentity(clientId); + if (identity.secretRef !== secretRef) return null; + const suppliedBotId = value.botId === undefined ? identity.botId : safeBotId(value.botId); + if (suppliedBotId !== identity.botId) return null; + return Object.freeze({ + botId: identity.botId, + clientId, + secretRef, + approvedSenders, + }); +} + +function normalizeDocument(value) { + if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null; + const bots = value.bots.map((bot) => normalizeBot(bot, { stored: true })); + if (bots.some((bot) => bot === null)) return null; + const botIds = new Set(); + const clientIds = new Set(); + const secretRefs = new Set(); + for (const bot of bots) { + if (botIds.has(bot.botId) || clientIds.has(bot.clientId) || secretRefs.has(bot.secretRef)) { + return null; + } + botIds.add(bot.botId); + clientIds.add(bot.clientId); + secretRefs.add(bot.secretRef); + } + return Object.freeze({ version: 1, bots: Object.freeze(bots) }); +} + +function storedDocument(document) { + return { + version: 1, + bots: document.bots.map((bot) => ({ + clientId: bot.clientId, + secretRef: bot.secretRef, + approvedSenders: bot.approvedSenders.map((sender) => ({ + senderKey: sender.senderKey, + staffId: sender.staffId, + displayName: sender.displayName, + approvedAt: sender.approvedAt, + })), + })), + }; +} + +/** Atomic non-secret DingTalk bot configuration store. */ +export class DingtalkConfigStore { + #path; + #value = EMPTY_DOCUMENT; + #writeQueue = Promise.resolve(); + + /** @param {string} path Absolute or process-relative configuration file path. */ + constructor(path) { + if (!cleanString(path)) throw new TypeError('config path is required'); + this.#path = path; + } + + /** @returns {Promise} Loaded store. */ + async load() { + try { + const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8'))); + if (!normalized) throw new Error('dsh-dingtalk config contains invalid bot data'); + this.#value = normalized; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + this.#value = EMPTY_DOCUMENT; + } + return this; + } + + /** @returns {Array} Cloned bot configurations with derived bot IDs. */ + list() { + return structuredClone(this.#value.bots); + } + + /** @param {string} botId Derived bot ID. @returns {object|null} Bot configuration. */ + get(botId) { + const found = this.#value.bots.find((bot) => bot.botId === botId); + return found ? structuredClone(found) : null; + } + + /** @param {string} clientId DingTalk client ID. @returns {object|null} Bot configuration. */ + getByClientId(clientId) { + const found = this.#value.bots.find((bot) => bot.clientId === clientId); + return found ? structuredClone(found) : null; + } + + /** @param {object} value Bot configuration without a client secret. @returns {Promise} Saved config. */ + async save(value) { + const normalized = normalizeBot(value); + if (!normalized) throw new Error('Refusing to persist invalid dsh-dingtalk bot data'); + return this.#mutate((bots) => { + const collision = bots.find( + (bot) => (bot.clientId === normalized.clientId || bot.secretRef === normalized.secretRef) + && bot.botId !== normalized.botId, + ); + if (collision) throw new Error('Duplicate DingTalk bot identity'); + const index = bots.findIndex((bot) => bot.botId === normalized.botId); + if (index === -1) bots.push(normalized); + else bots[index] = normalized; + return structuredClone(normalized); + }); + } + + /** @param {string} botId Derived bot ID. @returns {Promise} Removed config. */ + async remove(botId) { + if (!safeBotId(botId)) throw new TypeError('Invalid DingTalk 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); + }); + } + + /** Removes the configuration file and resets the in-memory store. */ + 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}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(storedDocument(document), null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + await rename(temporary, this.#path); + } catch (error) { + try { + await unlink(temporary); + } catch (cleanupError) { + if (cleanupError?.code !== 'ENOENT') throw new AggregateError([error, cleanupError]); + } + throw error; + } + } +} + +export { deriveDingtalkBotIdentity as deriveDingTalkBotIdentity }; +export { deriveDingtalkSenderKey as deriveDingTalkSenderKey }; +export { maskDingtalkClientId as maskDingTalkClientId }; +export { maskDingtalkSenderId as maskDingTalkSenderId }; +export { DingtalkConfigStore as DingTalkConfigStore }; diff --git a/src/channels/dingtalk/device-auth.mjs b/src/channels/dingtalk/device-auth.mjs new file mode 100644 index 0000000..84d9468 --- /dev/null +++ b/src/channels/dingtalk/device-auth.mjs @@ -0,0 +1,237 @@ +const DEFAULT_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com'; +const REGISTRATION_SOURCE = 'DING_DWS_CLAW'; + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function positiveNumber(value, fallback) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : fallback; +} + +function normalizeBaseUrl(value) { + let url; + try { + url = new URL(cleanString(value) ?? DEFAULT_REGISTRATION_BASE_URL); + } catch { + throw new TypeError('DingTalk registration base URL must be a valid HTTPS URL'); + } + const isDingtalkHost = url.hostname === 'dingtalk.com' || url.hostname.endsWith('.dingtalk.com'); + if (url.protocol !== 'https:' + || url.port + || !isDingtalkHost + || url.username + || url.password + || url.search + || url.hash) { + throw new TypeError('DingTalk registration base URL must be a valid HTTPS URL'); + } + url.pathname = url.pathname.replace(/\/+$/, ''); + return url.href.replace(/\/$/, ''); +} + +function readNow(clock) { + const value = typeof clock?.now === 'function' ? clock.now() : clock(); + if (!Number.isFinite(value)) throw new TypeError('clock must return a finite timestamp'); + return value; +} + +function assertRecord(value, action) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new DingtalkDeviceAuthError( + 'invalid-response', + `DingTalk ${action} returned an invalid response`, + action, + ); + } + if (Number(value.errcode) !== 0) { + throw new DingtalkDeviceAuthError( + 'api-error', + `DingTalk ${action} request was rejected`, + action, + ); + } + return value; +} + +/** A sanitized DingTalk device-registration failure. */ +export class DingtalkDeviceAuthError extends Error { + /** + * @param {string} code Stable failure code. + * @param {string} message Safe diagnostic that does not include response credentials. + * @param {string} action Registration stage that failed. + * @param {{cause?: unknown}} [options] Optional underlying error. + */ + constructor(code, message, action, options = {}) { + super(message, options); + this.name = 'DingtalkDeviceAuthError'; + this.code = code; + this.action = action; + } +} + +/** Host-only client for DingTalk's QR device-registration flow. */ +export class DingtalkDeviceAuth { + #fetch; + #clock; + #baseUrl; + #timeoutMs; + + /** + * @param {{fetch?: typeof globalThis.fetch, clock?: {now(): number}|(()=>number), baseUrl?: string, timeoutMs?: number}} [options] + * Device-registration dependencies. + */ + constructor({ + fetch = globalThis.fetch, + clock = Date, + baseUrl = DEFAULT_REGISTRATION_BASE_URL, + timeoutMs = 15_000, + } = {}) { + if (typeof fetch !== 'function') throw new TypeError('fetch is required'); + if (typeof clock !== 'function' && typeof clock?.now !== 'function') { + throw new TypeError('clock must be a function or expose now()'); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new TypeError('timeoutMs must be a positive number'); + } + this.#fetch = fetch; + this.#clock = clock; + this.#baseUrl = normalizeBaseUrl(baseUrl); + this.#timeoutMs = timeoutMs; + } + + /** + * Starts a QR registration and returns the host-only device code with QR metadata. + * @param {{signal?: AbortSignal}} [options] Optional cancellation signal. + * @returns {Promise} Device registration details. + */ + async start({ signal } = {}) { + const initialized = await this.#post( + '/app/registration/init', + { source: REGISTRATION_SOURCE }, + 'initialization', + signal, + ); + const nonce = cleanString(initialized.nonce); + if (!nonce) { + throw new DingtalkDeviceAuthError( + 'missing-nonce', + 'DingTalk registration initialization did not return a nonce', + 'initialization', + ); + } + + const begun = await this.#post( + '/app/registration/begin', + { nonce }, + 'begin', + signal, + ); + const deviceCode = cleanString(begun.device_code); + const verificationUrl = cleanString(begun.verification_uri_complete); + if (!deviceCode || !verificationUrl) { + throw new DingtalkDeviceAuthError( + 'incomplete-registration', + 'DingTalk registration did not return complete QR metadata', + 'begin', + ); + } + + const expiresInSeconds = positiveNumber(begun.expires_in, 7_200); + const pollIntervalMs = positiveNumber(begun.interval, 5) * 1_000; + return Object.freeze({ + deviceCode, + verificationUrl, + verificationUri: cleanString(begun.verification_uri), + userCode: cleanString(begun.user_code), + expiresAt: readNow(this.#clock) + expiresInSeconds * 1_000, + pollIntervalMs, + }); + } + + /** + * Polls one registration attempt. + * @param {{deviceCode: string, signal?: AbortSignal}|string} request Host-only device code. + * @returns {Promise} Normalized registration state and credentials on success. + */ + async poll(request) { + const deviceCode = cleanString(typeof request === 'string' ? request : request?.deviceCode); + const signal = typeof request === 'object' ? request?.signal : undefined; + if (!deviceCode) throw new TypeError('deviceCode is required'); + const response = await this.#post( + '/app/registration/poll', + { device_code: deviceCode }, + 'poll', + signal, + ); + const rawStatus = cleanString(response.status)?.toUpperCase(); + const status = ['WAITING', 'SUCCESS', 'FAIL', 'EXPIRED'].includes(rawStatus) + ? rawStatus + : 'UNKNOWN'; + return Object.freeze({ + status, + clientId: cleanString(response.client_id), + clientSecret: cleanString(response.client_secret), + failReason: cleanString(response.fail_reason), + }); + } + + async #post(path, body, action, signal) { + let response; + const timeoutSignal = AbortSignal.timeout(this.#timeoutMs); + const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + try { + response = await this.#fetch(`${this.#baseUrl}${path}`, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + redirect: 'error', + signal: requestSignal, + }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + if (timeoutSignal.aborted) { + throw new DingtalkDeviceAuthError( + 'timeout', + `DingTalk ${action} request timed out`, + action, + { cause: error }, + ); + } + if (error?.name === 'AbortError') throw error; + throw new DingtalkDeviceAuthError( + 'network-error', + `DingTalk ${action} request could not be completed`, + action, + { cause: error }, + ); + } + if (!response || response.ok === false || typeof response.json !== 'function') { + throw new DingtalkDeviceAuthError( + 'http-error', + `DingTalk ${action} request failed`, + action, + ); + } + let value; + try { + value = await response.json(); + } catch (error) { + throw new DingtalkDeviceAuthError( + 'invalid-json', + `DingTalk ${action} returned invalid JSON`, + action, + { cause: error }, + ); + } + return assertRecord(value, action); + } +} + +export { DEFAULT_REGISTRATION_BASE_URL, REGISTRATION_SOURCE }; +export { DingtalkDeviceAuth as DingTalkDeviceAuth }; +export { DingtalkDeviceAuthError as DingTalkDeviceAuthError }; diff --git a/src/channels/dingtalk/dingtalk-api.mjs b/src/channels/dingtalk/dingtalk-api.mjs new file mode 100644 index 0000000..9acf1b7 --- /dev/null +++ b/src/channels/dingtalk/dingtalk-api.mjs @@ -0,0 +1,579 @@ +import { randomUUID } from 'node:crypto'; + +export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/'; +export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/'; +export const DINGTALK_REGISTRATION_SOURCE = 'DING_DWS_CLAW'; +export const DINGTALK_AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema'; + +const DEFAULT_TIMEOUT_MS = 15_000; +const REGISTRATION_STATUSES = new Set(['WAITING', 'SUCCESS', 'FAIL', 'EXPIRED']); + +export class DingtalkApiError extends Error { + constructor(code, message, options = {}) { + super(message, options); + this.name = 'DingtalkApiError'; + this.code = code; + this.status = options.status; + } +} + +function nonEmptyString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function isDingtalkHost(hostname) { + const normalized = hostname.toLowerCase().replace(/\.$/, ''); + return normalized === 'dingtalk.com' || normalized.endsWith('.dingtalk.com'); +} + +function normalizeTrustedUrl(value, { label, requireSubdomain = true } = {}) { + let url; + try { + url = new URL(value); + } catch { + throw new DingtalkApiError('invalid-url', `${label ?? '钉钉服务'}返回了无效地址。`); + } + const normalizedHost = url.hostname.toLowerCase().replace(/\.$/, ''); + const trustedHost = requireSubdomain + ? normalizedHost !== 'dingtalk.com' && isDingtalkHost(normalizedHost) + : isDingtalkHost(normalizedHost); + if (url.protocol !== 'https:' || !trustedHost || (url.port && url.port !== '443')) { + throw new DingtalkApiError('untrusted-url', `${label ?? '钉钉服务'}地址不受信任。`); + } + if (url.username || url.password) { + throw new DingtalkApiError('untrusted-url', `${label ?? '钉钉服务'}地址不受信任。`); + } + return url; +} + +export function normalizeDingtalkSessionWebhook(value) { + const text = nonEmptyString(value); + if (!text) throw new DingtalkApiError('invalid-session-webhook', '钉钉消息没有可用的回复地址。'); + const url = normalizeTrustedUrl(text, { label: '钉钉回复', requireSubdomain: false }); + url.hash = ''; + return url.toString(); +} + +export function splitDingtalkText(value, maxChars = 4_000) { + const text = typeof value === 'string' ? value.trim() : ''; + if (!text) return []; + if (!Number.isInteger(maxChars) || maxChars < 1) throw new TypeError('maxChars must be a positive integer'); + if (text.length <= maxChars) return [text]; + + const chunks = []; + let remaining = text; + while (remaining.length > maxChars) { + let splitAt = remaining.lastIndexOf('\n', maxChars); + if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n+/, ''); + } + if (remaining) chunks.push(remaining); + return chunks; +} + +function abortError(signal) { + if (signal?.reason instanceof Error) return signal.reason; + return new DOMException('The operation was aborted', 'AbortError'); +} + +function abortableDelay(ms, signal) { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortError(signal)); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(abortError(signal)); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +async function requestJson(fetchImpl, url, { + body, + signal, + timeoutMs = DEFAULT_TIMEOUT_MS, + headers = {}, + method = 'POST', + action = 'request', +} = {}) { + const controller = new AbortController(); + let timedOut = false; + const onAbort = () => controller.abort(signal?.reason); + if (signal?.aborted) throw abortError(signal); + signal?.addEventListener('abort', onAbort, { once: true }); + const timer = timeoutMs > 0 ? setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs) : null; + + try { + const response = await fetchImpl(url, { + method, + redirect: 'error', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body ?? {}), + signal: controller.signal, + }); + if (!response.ok) { + throw new DingtalkApiError( + 'http-error', + `钉钉服务请求失败(HTTP ${response.status})。`, + { status: response.status }, + ); + } + try { + return await response.json(); + } catch (error) { + throw new DingtalkApiError('invalid-response', '钉钉服务返回了无法解析的响应。', { cause: error }); + } + } catch (error) { + if (signal?.aborted) throw abortError(signal); + if (timedOut) throw new DingtalkApiError('timeout', '钉钉服务请求超时。', { cause: error }); + if (error instanceof DingtalkApiError) throw error; + throw new DingtalkApiError('network-error', `暂时无法完成钉钉${action}请求。`, { cause: error }); + } finally { + if (timer) clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } +} + +function normalizeCardTarget(target) { + if (target?.type === 'user') { + const userId = nonEmptyString(target.userId); + if (userId) return { type: 'user', userId }; + } + if (target?.type === 'group') { + const openConversationId = nonEmptyString(target.openConversationId); + if (openConversationId) return { type: 'group', openConversationId }; + } + throw new TypeError('DingTalk AI Card target is invalid'); +} + +function cardData(text, flowStatus) { + return { + cardParamMap: { + flowStatus, + msgContent: normalizeDingtalkCardMarkdown(text), + staticMsgContent: '', + sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }), + config: JSON.stringify({ autoLayout: true }), + }, + }; +} + +function cardDeliverBody(cardInstanceId, target, robotCode) { + const base = { outTrackId: cardInstanceId, userIdType: 1 }; + if (target.type === 'group') { + return { + ...base, + openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`, + imGroupOpenDeliverModel: { robotCode }, + }; + } + return { + ...base, + openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`, + imRobotOpenDeliverModel: { + spaceType: 'IM_ROBOT', + robotCode, + extension: { dynamicSummary: 'true' }, + }, + }; +} + +export function normalizeDingtalkCardMarkdown(value) { + const text = typeof value === 'string' ? value.replace(/\r\n?/g, '\n') : ''; + const lines = text.split('\n'); + let inCodeBlock = false; + return lines.map((line, index) => { + const fenced = /^\s{0,3}```/.test(line); + const currentInCodeBlock = inCodeBlock; + if (fenced) inCodeBlock = !inCodeBlock; + if (index === lines.length - 1) return line; + if (currentInCodeBlock || fenced || inCodeBlock || !line || !lines[index + 1]) return `${line}\n`; + if (/^\s{0,3}(?:[-*+] |\d+[.)] |#{1,6} |\||> )/.test(lines[index + 1])) return `${line}\n`; + return `${line}
`; + }).join(''); +} + +function assertRegistrationOk(value, action) { + if (!value || typeof value !== 'object' || value.errcode !== 0) { + throw new DingtalkApiError( + 'registration-rejected', + `钉钉扫码${action}失败。`, + ); + } + return value; +} + +function positiveNumber(value, fallback) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : fallback; +} + +export function createDingtalkApi({ + fetchImpl = fetch, + registrationBaseUrl = process.env.DINGTALK_REGISTRATION_BASE_URL + || DINGTALK_REGISTRATION_BASE_URL, + registrationSource = process.env.DINGTALK_REGISTRATION_SOURCE + || DINGTALK_REGISTRATION_SOURCE, + now = () => Date.now(), + cardMinIntervalMs = 50, + cardBackoffMs = 1_000, + delay = abortableDelay, +} = {}) { + if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function'); + if (typeof now !== 'function') throw new TypeError('now must be a function'); + if (!Number.isFinite(cardMinIntervalMs) || cardMinIntervalMs < 0) { + throw new TypeError('cardMinIntervalMs must be a non-negative number'); + } + if (!Number.isFinite(cardBackoffMs) || cardBackoffMs < 0) { + throw new TypeError('cardBackoffMs must be a non-negative number'); + } + if (typeof delay !== 'function') throw new TypeError('delay must be a function'); + const registrationBase = normalizeTrustedUrl(registrationBaseUrl, { + label: '钉钉注册服务', + requireSubdomain: false, + }); + const apiBase = new URL(DINGTALK_API_BASE_URL); + const source = nonEmptyString(registrationSource); + if (!source) throw new TypeError('registrationSource is required'); + const tokenCache = new Map(); + const tokenRequests = new Map(); + let cardSlotTail = Promise.resolve(); + let nextCardRequestAt = 0; + + const endpoint = (base, pathname) => new URL(pathname.replace(/^\//, ''), base); + + async function accessToken({ clientId, clientSecret, signal }) { + const appKey = nonEmptyString(clientId); + const appSecret = nonEmptyString(clientSecret); + if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required'); + const cached = tokenCache.get(appKey); + if (cached && cached.expiresAt > now()) return cached.token; + if (tokenRequests.has(appKey)) return tokenRequests.get(appKey); + + const request = (async () => { + const value = await requestJson(fetchImpl, endpoint(apiBase, 'v1.0/oauth2/accessToken'), { + body: { appKey, appSecret }, + signal, + action: '鉴权', + }); + const token = nonEmptyString(value?.accessToken); + if (!token) throw new DingtalkApiError('invalid-access-token', '钉钉服务没有返回访问令牌。'); + const expiresInSeconds = positiveNumber(value?.expireIn ?? value?.expiresIn, 7_200); + const refreshAfterMs = Math.max(1_000, (expiresInSeconds - 60) * 1_000); + tokenCache.set(appKey, { token, expiresAt: now() + refreshAfterMs }); + return token; + })().finally(() => tokenRequests.delete(appKey)); + tokenRequests.set(appKey, request); + return request; + } + + function acquireCardRequestSlot(signal) { + const acquire = async () => { + const waitMs = Math.max(0, nextCardRequestAt - now()); + if (waitMs > 0) await delay(waitMs, signal); + nextCardRequestAt = Math.max(nextCardRequestAt, now()) + cardMinIntervalMs; + }; + const slot = cardSlotTail.then(acquire, acquire); + cardSlotTail = slot.catch(() => undefined); + return slot; + } + + async function cardRequest(pathname, options) { + await acquireCardRequestSlot(options.signal); + try { + return await requestJson(fetchImpl, endpoint(apiBase, pathname), options); + } catch (error) { + if (!(error instanceof DingtalkApiError) || error.status !== 403) throw error; + await delay(cardBackoffMs, options.signal); + await acquireCardRequestSlot(options.signal); + return requestJson(fetchImpl, endpoint(apiBase, pathname), options); + } + } + + async function failCard({ clientId, clientSecret, cardInstanceId, text, signal }) { + const instanceId = nonEmptyString(cardInstanceId); + const content = nonEmptyString(text); + if (!instanceId) throw new TypeError('cardInstanceId is required'); + if (!content) throw new TypeError('text is required'); + const token = await accessToken({ clientId, clientSecret, signal }); + const headers = { 'x-acs-dingtalk-access-token': token }; + const requests = [ + cardRequest('v1.0/card/streaming', { + method: 'PUT', + body: { + outTrackId: instanceId, + guid: randomUUID(), + key: 'msgContent', + content: normalizeDingtalkCardMarkdown(content), + isFull: true, + isFinalize: false, + isError: true, + }, + headers, + signal, + action: 'AI Card 失败收口', + }), + cardRequest('v1.0/card/instances', { + method: 'PUT', + body: { + outTrackId: instanceId, + cardData: cardData(content, '5'), + cardUpdateOptions: { updateCardDataByKey: true }, + }, + headers, + signal, + action: 'AI Card 失败状态', + }), + ]; + const results = await Promise.allSettled(requests); + if (results.every(({ status }) => status === 'rejected')) throw results[0].reason; + return true; + } + + return Object.freeze({ + async beginRegistration({ signal } = {}) { + const initialized = assertRegistrationOk(await requestJson( + fetchImpl, + endpoint(registrationBase, 'app/registration/init'), + { body: { source }, signal, action: '初始化' }, + ), '初始化'); + const nonce = nonEmptyString(initialized.nonce); + if (!nonce) throw new DingtalkApiError('invalid-registration', '钉钉扫码初始化缺少 nonce。'); + + const begun = assertRegistrationOk(await requestJson( + fetchImpl, + endpoint(registrationBase, 'app/registration/begin'), + { body: { nonce }, signal, action: '创建' }, + ), '创建'); + const deviceCode = nonEmptyString(begun.device_code); + const verificationUriComplete = nonEmptyString(begun.verification_uri_complete); + if (!deviceCode || !verificationUriComplete) { + throw new DingtalkApiError('invalid-registration', '钉钉扫码服务返回的信息不完整。'); + } + const verificationUrl = normalizeTrustedUrl(verificationUriComplete, { + label: '钉钉扫码', + requireSubdomain: false, + }).toString(); + return { + deviceCode, + userCode: nonEmptyString(begun.user_code) ?? undefined, + verificationUri: nonEmptyString(begun.verification_uri) ?? undefined, + verificationUriComplete: verificationUrl, + expiresInSeconds: positiveNumber(begun.expires_in, 7_200), + intervalSeconds: positiveNumber(begun.interval, 5), + }; + }, + + async pollRegistration({ deviceCode, signal } = {}) { + const code = nonEmptyString(deviceCode); + if (!code) throw new TypeError('deviceCode is required'); + const polled = assertRegistrationOk(await requestJson( + fetchImpl, + endpoint(registrationBase, 'app/registration/poll'), + { body: { device_code: code }, signal, action: '状态查询' }, + ), '状态查询'); + const status = nonEmptyString(polled.status)?.toUpperCase(); + if (!status || !REGISTRATION_STATUSES.has(status)) { + throw new DingtalkApiError('invalid-registration-status', '钉钉扫码服务返回了无法识别的状态。'); + } + const result = { + status, + failReason: nonEmptyString(polled.fail_reason) ?? undefined, + }; + if (status === 'SUCCESS') { + result.clientId = nonEmptyString(polled.client_id) ?? undefined; + result.clientSecret = nonEmptyString(polled.client_secret) ?? undefined; + if (!result.clientId || !result.clientSecret) { + throw new DingtalkApiError('missing-credentials', '钉钉扫码已确认,但没有返回机器人凭据。'); + } + } + return result; + }, + + accessToken, + + async createAiCard({ clientId, clientSecret, target, initialText, signal }) { + const appKey = nonEmptyString(clientId); + const appSecret = nonEmptyString(clientSecret); + const content = nonEmptyString(initialText); + if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required'); + if (!content) throw new TypeError('initialText is required'); + const normalizedTarget = normalizeCardTarget(target); + const token = await accessToken({ clientId: appKey, clientSecret: appSecret, signal }); + const cardInstanceId = `dsh_${randomUUID()}`; + const headers = { 'x-acs-dingtalk-access-token': token }; + + let delivered = false; + try { + await cardRequest('v1.0/card/instances', { + body: { + cardTemplateId: DINGTALK_AI_CARD_TEMPLATE_ID, + outTrackId: cardInstanceId, + cardData: { + cardParamMap: { config: JSON.stringify({ autoLayout: true }) }, + }, + callbackType: 'STREAM', + imGroupOpenSpaceModel: { supportForward: true }, + imRobotOpenSpaceModel: { supportForward: true }, + }, + headers, + signal, + action: 'AI Card 创建', + }); + await cardRequest('v1.0/card/instances/deliver', { + body: cardDeliverBody(cardInstanceId, normalizedTarget, appKey), + headers, + signal, + action: 'AI Card 投放', + }); + delivered = true; + await cardRequest('v1.0/card/instances', { + method: 'PUT', + body: { outTrackId: cardInstanceId, cardData: cardData(content, '2') }, + headers, + signal, + action: 'AI Card 启动', + }); + await cardRequest('v1.0/card/streaming', { + method: 'PUT', + body: { + outTrackId: cardInstanceId, + guid: randomUUID(), + key: 'msgContent', + content: normalizeDingtalkCardMarkdown(content).replace(/\n+$/, ''), + isFull: true, + isFinalize: false, + isError: false, + }, + headers, + signal, + action: 'AI Card 启动', + }); + } catch (error) { + if (delivered) { + const cleanupSignal = AbortSignal.timeout(5_000); + await failCard({ + clientId: appKey, + clientSecret: appSecret, + cardInstanceId, + text: '消息处理失败,请稍后重试。', + signal: cleanupSignal, + }).catch(() => undefined); + } + throw error; + } + return { cardInstanceId }; + }, + + async updateAiCard({ clientId, clientSecret, cardInstanceId, text, signal }) { + const instanceId = nonEmptyString(cardInstanceId); + const content = nonEmptyString(text); + if (!instanceId) throw new TypeError('cardInstanceId is required'); + if (!content) throw new TypeError('text is required'); + const token = await accessToken({ clientId, clientSecret, signal }); + await cardRequest('v1.0/card/streaming', { + method: 'PUT', + body: { + outTrackId: instanceId, + guid: randomUUID(), + key: 'msgContent', + content: normalizeDingtalkCardMarkdown(content).replace(/\n+$/, ''), + isFull: true, + isFinalize: false, + isError: false, + }, + headers: { 'x-acs-dingtalk-access-token': token }, + signal, + action: 'AI Card 更新', + }); + return true; + }, + + async finishAiCard({ clientId, clientSecret, cardInstanceId, text, signal }) { + const instanceId = nonEmptyString(cardInstanceId); + const content = nonEmptyString(text); + if (!instanceId) throw new TypeError('cardInstanceId is required'); + if (!content) throw new TypeError('text is required'); + const token = await accessToken({ clientId, clientSecret, signal }); + const headers = { 'x-acs-dingtalk-access-token': token }; + const normalizedContent = normalizeDingtalkCardMarkdown(content); + await cardRequest('v1.0/card/streaming', { + method: 'PUT', + body: { + outTrackId: instanceId, + guid: randomUUID(), + key: 'msgContent', + content: normalizedContent, + isFull: true, + isFinalize: true, + isError: false, + }, + headers, + signal, + action: 'AI Card 完成', + }); + let completed = true; + const completionRequest = { + method: 'PUT', + body: { + outTrackId: instanceId, + cardData: cardData(content, '3'), + cardUpdateOptions: { updateCardDataByKey: true }, + }, + headers, + signal, + action: 'AI Card 收口', + }; + try { + await cardRequest('v1.0/card/instances', completionRequest); + } catch { + try { + await cardRequest('v1.0/card/instances', completionRequest); + } catch { + completed = false; + } + } + return { delivered: true, completed }; + }, + + failAiCard: failCard, + + async sendText({ clientId, clientSecret, sessionWebhook, text, signal }) { + const content = nonEmptyString(text); + if (!content) throw new TypeError('text is required'); + const webhook = normalizeDingtalkSessionWebhook(sessionWebhook); + const token = await accessToken({ clientId, clientSecret, signal }); + const response = await requestJson(fetchImpl, webhook, { + body: { msgtype: 'text', text: { content } }, + headers: { 'x-acs-dingtalk-access-token': token }, + signal, + action: '消息回复', + }); + if ((response?.errcode !== undefined && response.errcode !== 0) + || (response?.code !== undefined && response.code !== 0)) { + throw new DingtalkApiError('send-rejected', '钉钉服务拒绝了回复消息。'); + } + return true; + }, + + clearAccessToken(clientId) { + const appKey = nonEmptyString(clientId); + if (appKey) tokenCache.delete(appKey); + }, + }); +} + +export const createDingTalkApi = createDingtalkApi; +export const normalizeDingTalkSessionWebhook = normalizeDingtalkSessionWebhook; +export const splitDingTalkText = splitDingtalkText; diff --git a/src/channels/dingtalk/dingtalk-bridge.mjs b/src/channels/dingtalk/dingtalk-bridge.mjs new file mode 100644 index 0000000..5d051a4 --- /dev/null +++ b/src/channels/dingtalk/dingtalk-bridge.mjs @@ -0,0 +1,281 @@ +import { + normalizeDingtalkSessionWebhook, + splitDingtalkText, +} from './dingtalk-api.mjs'; +import { createDingTalkCardStream } from './dingtalk-card-stream.mjs'; + +const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…'; +const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。'; + +const HELP_TEXT = [ + '钉钉机器人已连接 DeepSeek Harness。', + '', + '直接发送文字即可继续当前会话。', + '/new 开启一个全新会话', + '/status 检查连接状态', + '/help 显示本帮助', +].join('\n'); + +function nonEmptyString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function senderStaffId(message) { + return nonEmptyString(message?.senderStaffId) ?? nonEmptyString(message?.senderId); +} + +function conversationKey(message, sender) { + if (String(message?.conversationType) === '2') { + const conversationId = nonEmptyString(message?.conversationId); + if (!conversationId) throw new Error('DingTalk group message has no conversation id'); + return `group:${conversationId}`; + } + return `p2p:${sender}`; +} + +function cardTarget(message, sender) { + if (String(message?.conversationType) === '2') { + return { type: 'group', openConversationId: nonEmptyString(message?.conversationId) }; + } + return { type: 'user', userId: sender }; +} + +function progressText(update) { + if (update?.type === 'text' && nonEmptyString(update.text)) return update.text; + if (update?.type === 'tool') { + if (update.name === 'web_search') return '_正在搜索网络并整理信息…_'; + return `_正在使用 ${nonEmptyString(update.name) ?? '工具'}…_`; + } + return `_${nonEmptyString(update?.text) ?? '正在处理…'}_`; +} + +function ensureStats(status) { + status.stats ??= {}; + for (const key of ['messagesReceived', 'messagesReplied', 'messagesRejected', 'messagesIgnored']) { + status[key] ??= 0; + status.stats[key] = status[key]; + } + status.pendingSenders ??= []; +} + +function increment(status, key) { + status[key] = (status[key] ?? 0) + 1; + status.stats ??= {}; + status.stats[key] = status[key]; +} + +export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) { + return { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + messagesIgnored: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null, + pendingSenders: structuredClone(pendingSenders), + stats: { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + messagesIgnored: 0, + }, + }; +} + +export class DingtalkHarnessBridge { + #api; + #clientId; + #clientSecret; + #harness; + #state; + #status; + #logger; + #replyTimeoutMs; + #maxMessageChars; + #signal; + #queues = new Map(); + #acceptedMessageIds = new Set(); + + constructor({ + api, + clientId, + clientSecret, + harness, + state, + status = createDingtalkBridgeStatus(), + logger = console, + replyTimeoutMs = 600_000, + maxMessageChars = 4_000, + signal, + }) { + if (!api || typeof api.sendText !== 'function') throw new TypeError('DingTalk API is required'); + if (!nonEmptyString(clientId) || !nonEmptyString(clientSecret)) { + throw new TypeError('DingTalk app credentials are required'); + } + if (!harness || !state) throw new TypeError('Harness client and state store are required'); + this.#api = api; + this.#clientId = clientId.trim(); + this.#clientSecret = clientSecret.trim(); + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#maxMessageChars = maxMessageChars; + this.#signal = signal; + ensureStats(this.#status); + this.#refreshPendingSenders(); + } + + get status() { + this.#refreshPendingSenders(); + return structuredClone(this.#status); + } + + accept(message) { + if (this.#signal?.aborted) return Promise.resolve(); + const messageId = nonEmptyString(message?.msgId); + const sender = senderStaffId(message); + if (!messageId || !sender || this.#state.hasSeen(messageId) + || this.#acceptedMessageIds.has(messageId)) return Promise.resolve(); + this.#acceptedMessageIds.add(messageId); + + let key; + try { + key = conversationKey(message, sender); + } catch { + this.#acceptedMessageIds.delete(messageId); + increment(this.#status, 'messagesRejected'); + this.#status.lastRejectedAt = new Date().toISOString(); + return Promise.resolve(); + } + const previous = this.#queues.get(key) ?? Promise.resolve(); + const current = previous + .catch(() => undefined) + .then(() => this.#process(message, messageId, sender, key)) + .finally(() => { + this.#acceptedMessageIds.delete(messageId); + 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 #process(message, messageId, sender, key) { + this.#signal?.throwIfAborted(); + if (this.#state.hasSeen(messageId)) return; + await this.#state.markSeen(messageId); + increment(this.#status, 'messagesReceived'); + this.#status.lastMessageAt = new Date().toISOString(); + + if (String(message.conversationType) === '2' && message.isInAtList !== true) { + increment(this.#status, 'messagesIgnored'); + return; + } + + let sessionWebhook; + try { + sessionWebhook = normalizeDingtalkSessionWebhook(message.sessionWebhook); + } catch { + increment(this.#status, 'messagesRejected'); + this.#status.lastRejectedAt = new Date().toISOString(); + this.#status.lastError = '钉钉消息没有安全的回复地址。'; + return; + } + + const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null; + let cardStream = null; + let cardStarted = false; + try { + if (!text) { + await this.#send(sessionWebhook, '目前仅支持文字消息。'); + return; + } + + const command = text.toLowerCase(); + if (command === '/help') { + await this.#send(sessionWebhook, HELP_TEXT); + return; + } + if (command === '/status') { + await this.#harness.ensureRunning({ signal: this.#signal }); + await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。'); + return; + } + if (command === '/new') { + await this.#state.clearSession(key); + await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。'); + return; + } + + let sessionId = this.#state.sessionFor(key); + if (!sessionId || !(await this.#harness.sessionExists(sessionId, { signal: this.#signal }))) { + sessionId = await this.#harness.createSession({ signal: this.#signal }); + await this.#state.setSession(key, sessionId); + } + if (typeof this.#api.createAiCard === 'function' + && typeof this.#api.updateAiCard === 'function' + && typeof this.#api.finishAiCard === 'function') { + cardStream = createDingTalkCardStream({ + api: this.#api, + clientId: this.#clientId, + clientSecret: this.#clientSecret, + target: cardTarget(message, sender), + signal: this.#signal, + logger: this.#logger, + }); + cardStarted = await cardStream.start(CARD_INITIAL_TEXT); + } + const answer = await this.#harness.ask(sessionId, text, { + timeoutMs: this.#replyTimeoutMs, + signal: this.#signal, + onUpdate: cardStarted + ? (update) => cardStream.push(progressText(update)) + : undefined, + }); + const streamed = cardStarted && await cardStream.finish(answer); + if (!streamed) await this.#send(sessionWebhook, answer); + increment(this.#status, 'messagesReplied'); + this.#status.lastReplyAt = new Date().toISOString(); + this.#status.lastError = null; + } catch { + if (this.#signal?.aborted) return; + this.#status.lastError = '钉钉消息处理失败。'; + this.#logger.error?.('[dsh-dingtalk] failed to process an inbound message'); + try { + const streamed = cardStarted && await cardStream.finish(CARD_ERROR_TEXT); + if (!streamed) await this.#send(sessionWebhook, CARD_ERROR_TEXT); + } catch { + this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply'); + } + } + } + + #refreshPendingSenders() { + if (typeof this.#state.pendingSenders === 'function') { + this.#status.pendingSenders = this.#state.pendingSenders(); + } + } + + async #send(sessionWebhook, text) { + for (const chunk of splitDingtalkText(text, this.#maxMessageChars)) { + this.#signal?.throwIfAborted(); + await this.#api.sendText({ + clientId: this.#clientId, + clientSecret: this.#clientSecret, + sessionWebhook, + text: chunk, + signal: this.#signal, + }); + } + } +} + +export const DingTalkHarnessBridge = DingtalkHarnessBridge; +export const createDingTalkBridgeStatus = createDingtalkBridgeStatus; diff --git a/src/channels/dingtalk/dingtalk-card-stream.mjs b/src/channels/dingtalk/dingtalk-card-stream.mjs new file mode 100644 index 0000000..4f11221 --- /dev/null +++ b/src/channels/dingtalk/dingtalk-card-stream.mjs @@ -0,0 +1,233 @@ +const DEFAULT_UPDATE_INTERVAL_MS = 500; +const FAILURE_TEXT = '消息处理失败,请稍后重试。'; + +function requiredText(value, name) { + if (typeof value !== 'string') throw new TypeError(`${name} must be a string`); + return value; +} + +function requiredCredential(value, name) { + if (typeof value !== 'string' || !value.trim()) { + throw new TypeError(`${name} is required`); + } + return value.trim(); +} + +/** + * Creates one throttled DingTalk AI Card update stream. + * + * The stream owns a single card instance. Progress updates use latest-wins + * buffering, while finish waits for an active update before sending the final + * card content. + * + * @param {object} options Stream dependencies and DingTalk request data. + * @param {object} options.api DingTalk AI Card API implementation. + * @param {string} options.clientId DingTalk application client id. + * @param {string} options.clientSecret DingTalk application client secret. + * @param {unknown} options.target DingTalk card delivery target. + * @param {AbortSignal} [options.signal] Stream cancellation signal. + * @param {object} [options.logger] Safe diagnostic sink. + * @param {number} [options.updateIntervalMs=500] Minimum delay between updates. + * @param {()=>number} [options.clock] Monotonic millisecond clock. + * @param {{setTimeout: Function, clearTimeout: Function}} [options.timer] Timer implementation. + * @returns {{start(initialText: string): Promise, push(progressText: string): void, finish(finalText: string): Promise}} + * Card stream controller. + */ +export function createDingTalkCardStream({ + api, + clientId, + clientSecret, + target, + signal, + logger = console, + updateIntervalMs = DEFAULT_UPDATE_INTERVAL_MS, + clock = () => Date.now(), + timer = { + setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay), + clearTimeout: (handle) => globalThis.clearTimeout(handle), + }, +} = {}) { + if (!api + || typeof api.createAiCard !== 'function' + || typeof api.updateAiCard !== 'function' + || typeof api.finishAiCard !== 'function') { + throw new TypeError('DingTalk AI Card API is required'); + } + const normalizedClientId = requiredCredential(clientId, 'clientId'); + const normalizedClientSecret = requiredCredential(clientSecret, 'clientSecret'); + if (target === undefined || target === null) throw new TypeError('target is required'); + if (!Number.isFinite(updateIntervalMs) || updateIntervalMs < 0) { + throw new TypeError('updateIntervalMs must be a non-negative number'); + } + if (typeof clock !== 'function') throw new TypeError('clock must be a function'); + if (typeof timer?.setTimeout !== 'function' || typeof timer?.clearTimeout !== 'function') { + throw new TypeError('timer must provide setTimeout and clearTimeout'); + } + + const readClock = () => { + const value = clock(); + if (!Number.isFinite(value)) throw new TypeError('clock must return a finite timestamp'); + return value; + }; + readClock(); + + let phase = signal?.aborted ? 'aborted' : 'idle'; + let cardRequest = null; + let pendingText = null; + let scheduledUpdate = null; + let updateWorker = null; + let finishPromise = null; + let cleanupPromise = null; + let lastUpdateAt = 0; + + const clearScheduledUpdate = () => { + if (scheduledUpdate === null) return; + timer.clearTimeout(scheduledUpdate); + scheduledUpdate = null; + }; + + const removeAbortListener = () => signal?.removeEventListener('abort', onAbort); + + const close = (nextPhase) => { + phase = nextPhase; + pendingText = null; + clearScheduledUpdate(); + removeAbortListener(); + }; + + const cleanupCard = () => { + if (!cardRequest || typeof api.failAiCard !== 'function') return Promise.resolve(false); + if (!cleanupPromise) { + cleanupPromise = api.failAiCard({ + ...cardRequest, + text: FAILURE_TEXT, + signal: AbortSignal.timeout(5_000), + }).then( + () => true, + () => false, + ); + } + return cleanupPromise; + }; + + const fail = (operation) => { + if (phase === 'failed' || phase === 'finished' || phase === 'aborted') return; + void cleanupCard(); + close('failed'); + logger?.error?.(`[dsh-dingtalk] AI Card ${operation} failed`); + }; + + function onAbort() { + if (phase === 'finished' || phase === 'failed' || phase === 'aborted') return; + void cleanupCard(); + close('aborted'); + } + + if (phase !== 'aborted') signal?.addEventListener('abort', onAbort, { once: true }); + + const launchUpdate = () => { + if (phase !== 'active' || updateWorker || pendingText === null) return; + const delay = Math.max(0, lastUpdateAt + updateIntervalMs - readClock()); + if (delay > 0) { + scheduledUpdate = timer.setTimeout(() => { + scheduledUpdate = null; + launchUpdate(); + }, delay); + return; + } + + const text = pendingText; + pendingText = null; + updateWorker = (async () => { + try { + await api.updateAiCard({ ...cardRequest, text, finished: false }); + lastUpdateAt = readClock(); + } catch { + if (signal?.aborted || phase === 'aborted') return; + fail('update'); + } + })().finally(() => { + updateWorker = null; + if (phase === 'active' && pendingText !== null) launchUpdate(); + }); + }; + + const start = async (initialText) => { + requiredText(initialText, 'initialText'); + if (phase !== 'idle') return false; + phase = 'starting'; + try { + const created = await api.createAiCard({ + clientId: normalizedClientId, + clientSecret: normalizedClientSecret, + target, + initialText, + signal, + }); + const cardInstanceId = typeof created?.cardInstanceId === 'string' + ? created.cardInstanceId.trim() + : ''; + if (!cardInstanceId) throw new TypeError('DingTalk did not return a card instance id'); + cardRequest = Object.freeze({ + clientId: normalizedClientId, + clientSecret: normalizedClientSecret, + target, + cardInstanceId, + signal, + }); + if (phase !== 'starting') { + void cleanupCard(); + return false; + } + lastUpdateAt = readClock(); + phase = 'active'; + return true; + } catch { + if (signal?.aborted || phase === 'aborted') { + close('aborted'); + return false; + } + fail('creation'); + return false; + } + }; + + const push = (progressText) => { + requiredText(progressText, 'progressText'); + if (phase !== 'active') return; + pendingText = progressText; + if (!scheduledUpdate && !updateWorker) launchUpdate(); + }; + + const finish = (finalText) => { + requiredText(finalText, 'finalText'); + if (phase === 'finished') return Promise.resolve(true); + if (phase === 'finishing') return finishPromise; + if (phase !== 'active') return Promise.resolve(false); + + phase = 'finishing'; + pendingText = null; + clearScheduledUpdate(); + const activeUpdate = updateWorker; + finishPromise = (async () => { + if (activeUpdate) await activeUpdate; + if (phase !== 'finishing') return false; + try { + await api.finishAiCard({ ...cardRequest, text: finalText }); + if (phase !== 'finishing') return false; + close('finished'); + return true; + } catch { + if (signal?.aborted || phase === 'aborted') { + close('aborted'); + return false; + } + fail('finish'); + return false; + } + })(); + return finishPromise; + }; + + return Object.freeze({ start, push, finish }); +} diff --git a/src/channels/dingtalk/dingtalk-controller.mjs b/src/channels/dingtalk/dingtalk-controller.mjs new file mode 100644 index 0000000..8388e27 --- /dev/null +++ b/src/channels/dingtalk/dingtalk-controller.mjs @@ -0,0 +1,708 @@ +import { randomUUID } from 'node:crypto'; + +import { + deriveDingtalkBotIdentity, + deriveDingtalkSenderKey, + maskDingtalkClientId, + maskDingtalkSenderId, +} from './config-store.mjs'; + +const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']); +const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']); + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function safeError(code, message) { + return Object.freeze({ code, message }); +} + +function nowFrom(clock) { + return typeof clock?.now === 'function' ? clock.now() : clock(); +} + +function isoNow(clock) { + return new Date(nowFrom(clock)).toISOString(); +} + +function abortError() { + return new DOMException('DingTalk provisioning was cancelled', 'AbortError'); +} + +function publicAttempt(record) { + if (!record) return null; + return { + attemptId: record.id, + status: record.state, + ...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}), + ...(record.expiresAt ? { expiresAt: record.expiresAt } : {}), + ...(record.pollIntervalMs ? { pollIntervalMs: record.pollIntervalMs } : {}), + ...(record.botId ? { botId: record.botId } : {}), + ...(record.alreadyConnected ? { alreadyConnected: true } : {}), + ...(record.error ? { error: structuredClone(record.error) } : {}), + }; +} + +function runtimeStatus(runtime) { + if (!runtime) return {}; + const value = typeof runtime.status === 'function' ? runtime.status() : runtime.status; + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +function isRuntimeConnected(runtime, status) { + if (!runtime) return false; + if (status.connected === false || status.ready === false) return false; + const state = cleanString( + status.dingtalkStreamState + ?? status.dingtalkConnectionState + ?? status.connectionState + ?? status.state, + )?.toLowerCase(); + if (['failed', 'error', 'offline', 'disconnected', 'stopped'].includes(state)) return false; + return status.connected === true + || status.ready === true + || state === 'connected' + || state === 'ready'; +} + +function normalizePendingSender(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const staffId = cleanString(value.staffId ?? value.senderStaffId ?? value.senderId); + if (!staffId) return null; + const suppliedRequestId = cleanString(value.requestId); + const opaqueRequestId = suppliedRequestId + && /^ding_sender_[A-Za-z0-9_-]{1,100}$/.test(suppliedRequestId) + && !suppliedRequestId.includes(staffId) + ? suppliedRequestId + : null; + if (!opaqueRequestId) return null; + return { + requestId: opaqueRequestId, + staffId, + displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? '钉钉用户', + requestedAt: cleanString(value.requestedAt), + }; +} + +function internalPendingSenders(status) { + if (!Array.isArray(status.pendingSenders)) return []; + const seen = new Set(); + const senders = []; + for (const value of status.pendingSenders) { + const sender = normalizePendingSender(value); + if (!sender || seen.has(sender.staffId)) continue; + seen.add(sender.staffId); + senders.push(sender); + } + return senders; +} + +function publicPendingSender(sender) { + return { + requestId: sender.requestId, + displayName: sender.displayName, + senderIdMasked: maskDingtalkSenderId(sender.staffId), + requestedAt: sender.requestedAt, + }; +} + +function publicApprovedSender(sender) { + return { + senderKey: sender.senderKey, + displayName: cleanString(sender.displayName) ?? '钉钉用户', + senderIdMasked: maskDingtalkSenderId(sender.staffId), + approvedAt: cleanString(sender.approvedAt), + }; +} + +/** Coordinates DingTalk QR registration, credentials, runtimes, and sender approvals. */ +export class DingtalkController { + #deviceAuth; + #credentials; + #configStore; + #createRuntime; + #deleteState; + #logger; + #clock; + #runtimes = new Map(); + #errors = new Map(); + #attempts = new Map(); + #activeAttemptId = null; + #transitions = new Map(); + #revision = 0; + #closed = false; + + /** + * @param {object} options Controller dependencies. + * @param {object} options.deviceAuth Host-only DingTalk device auth client. + * @param {object} options.credentials DSH credential provider. + * @param {object} options.configStore Loaded DingTalk config store. + * @param {Function} options.createRuntime Runtime factory. + * @param {Function} [options.deleteState] Per-bot state cleanup callback. + * @param {Console} [options.logger] Host logger. + * @param {{now(): number}|(()=>number)} [options.clock] Injectable clock. + */ + constructor({ + deviceAuth, + credentials, + configStore, + createRuntime, + deleteState = async () => {}, + logger = console, + clock = Date, + }) { + if (!deviceAuth + || typeof deviceAuth.start !== 'function' + || typeof deviceAuth.poll !== 'function') { + throw new TypeError('DingtalkController requires a DingTalk device auth client'); + } + if (!credentials + || typeof credentials.resolve !== 'function' + || typeof credentials.set !== 'function' + || typeof credentials.unset !== 'function') { + throw new TypeError('DingtalkController requires the DSH credential provider'); + } + if (!configStore + || typeof configStore.list !== 'function' + || typeof configStore.get !== 'function' + || typeof configStore.getByClientId !== 'function' + || typeof configStore.save !== 'function' + || typeof configStore.remove !== 'function') { + throw new TypeError('DingtalkController requires a loaded config store'); + } + if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required'); + if (typeof deleteState !== 'function') throw new TypeError('deleteState must be a function'); + if (typeof clock !== 'function' && typeof clock?.now !== 'function') { + throw new TypeError('clock must be a function or expose now()'); + } + this.#deviceAuth = deviceAuth; + this.#credentials = credentials; + this.#configStore = configStore; + this.#createRuntime = createRuntime; + this.#deleteState = deleteState; + this.#logger = logger; + this.#clock = clock; + } + + /** Starts all configured DingTalk runtimes whose secrets are available. */ + async initialize() { + if (this.#closed) return this.status(); + for (const config of this.#configStore.list()) { + const current = this.#runtimes.get(config.botId); + try { + if (isRuntimeConnected(current, runtimeStatus(current))) continue; + } catch { + // A runtime with an unreadable status is replaced below. + } + await this.#withBotTransition(config.botId, async () => { + const latest = this.#configStore.get(config.botId); + if (!latest || this.#closed) return; + const clientSecret = await this.#resolveSecret(latest.secretRef); + if (!clientSecret) { + this.#errors.set( + latest.botId, + safeError('missing-secret', '钉钉机器人凭据缺失,请移除后重新扫码。'), + ); + this.#touch(); + return; + } + try { + await this.#startRuntime(latest, clientSecret); + this.#errors.delete(latest.botId); + } catch { + this.#errors.set( + latest.botId, + safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'), + ); + this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`); + } + this.#touch(); + }); + } + return this.status(); + } + + /** Starts one DingTalk QR registration, cancelling any prior active attempt. */ + async startProvisioning({ signal } = {}) { + if (this.#closed) throw new Error('dsh-dingtalk controller is closed'); + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + const record = { + id: randomUUID(), + state: 'starting', + controller: new AbortController(), + deviceCode: null, + verificationUrl: null, + expiresAt: null, + pollIntervalMs: null, + pollTask: null, + botId: null, + alreadyConnected: false, + error: null, + }; + this.#attempts.set(record.id, record); + this.#activeAttemptId = record.id; + this.#touch(); + const abortFromRequest = () => record.controller.abort(signal?.reason); + if (signal?.aborted) abortFromRequest(); + else signal?.addEventListener('abort', abortFromRequest, { once: true }); + try { + const begun = await this.#deviceAuth.start({ signal: record.controller.signal }); + this.#assertAttemptActive(record); + record.deviceCode = cleanString(begun.deviceCode); + record.verificationUrl = cleanString(begun.verificationUrl); + record.expiresAt = Number(begun.expiresAt); + record.pollIntervalMs = Number(begun.pollIntervalMs); + if (!record.deviceCode + || !record.verificationUrl + || !Number.isFinite(record.expiresAt) + || !Number.isFinite(record.pollIntervalMs) + || record.pollIntervalMs <= 0) { + throw new Error('DingTalk device auth returned incomplete registration metadata'); + } + record.state = 'pending'; + this.#touch(); + return publicAttempt(record); + } catch (error) { + if (record.controller.signal.aborted || error?.name === 'AbortError') { + record.state = 'cancelled'; + record.error = safeError('cancelled', '扫码接入已取消。'); + } else { + record.state = 'failed'; + record.error = safeError('qr-start-failed', '无法生成钉钉二维码,请稍后重试。'); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + if (record.state === 'failed') throw error; + return publicAttempt(record); + } finally { + signal?.removeEventListener('abort', abortFromRequest); + } + } + + /** Polls one QR registration without exposing its device code or returned secret. */ + async registrationStatus(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record) return null; + if (TERMINAL_ATTEMPT_STATES.has(record.state) || record.state === 'starting') { + return publicAttempt(record); + } + if (nowFrom(this.#clock) >= record.expiresAt) { + record.state = 'expired'; + record.error = safeError('expired', '二维码已过期,请重新生成。'); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + return publicAttempt(record); + } + if (!record.pollTask) { + const task = this.#pollRegistration(record).finally(() => { + if (record.pollTask === task) record.pollTask = null; + }); + record.pollTask = task; + } + await record.pollTask; + return publicAttempt(record); + } + + /** Cancels an active QR registration. */ + async cancelProvisioning(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record) return null; + if (!TERMINAL_ATTEMPT_STATES.has(record.state)) { + record.controller.abort(); + await record.pollTask?.catch(() => undefined); + if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled'; + record.error ??= safeError('cancelled', '扫码接入已取消。'); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + return publicAttempt(record); + } + + /** Replaces one bot runtime using its stored credential. */ + async reconnectBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error('Unknown DingTalk bot'); + await this.#withBotTransition(botId, async () => { + const clientSecret = await this.#resolveSecret(config.secretRef); + if (!clientSecret) throw new Error('The DingTalk client secret is missing'); + try { + await this.#startRuntime(config, clientSecret); + this.#errors.delete(botId); + } catch (error) { + this.#errors.set( + botId, + safeError('connection-failed', '钉钉连接仍未就绪,请稍后重试。'), + ); + throw error; + } finally { + this.#touch(); + } + }); + return this.status(); + } + + /** Removes one bot, its secret, runtime, and local conversation state. */ + async deleteBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error('Unknown DingTalk bot'); + await this.#withBotTransition(botId, async () => { + const previousSecret = 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 (cleanString(previousSecret?.value)) { + await this.#credentials.set(config.secretRef, previousSecret.value).catch(() => undefined); + await this.#startRuntime(config, previousSecret.value).catch(() => undefined); + } + throw new Error('Unable to remove the DingTalk bot safely.', { cause: error }); + } + try { + await this.#deleteState({ botId, config }); + } catch { + this.#logger.warn?.(`[dsh-dingtalk] bot ${botId} state cleanup failed`); + } + this.#errors.delete(botId); + this.#touch(); + }); + return this.status(); + } + + /** Approves one opaque pending-sender request for a bot. */ + async approveSender(botId, requestId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error('Unknown DingTalk bot'); + const runtime = this.#runtimes.get(botId); + const direct = typeof runtime?.pendingSender === 'function' + ? normalizePendingSender(runtime.pendingSender(requestId)) + : null; + const pending = internalPendingSenders(runtimeStatus(runtime)); + const sender = direct?.requestId === requestId + ? direct + : pending.find((candidate) => candidate.requestId === requestId); + if (!sender) throw new Error('Unknown DingTalk sender approval request'); + if (config.approvedSenders.some((approved) => approved.staffId === sender.staffId)) { + return this.status(); + } + const updated = { + ...config, + approvedSenders: [ + ...config.approvedSenders, + { + senderKey: deriveDingtalkSenderKey(), + staffId: sender.staffId, + displayName: sender.displayName, + approvedAt: isoNow(this.#clock), + }, + ], + }; + await this.#saveAndRestart(config, updated); + return this.status(); + } + + /** Revokes one approved sender by its browser-safe sender key. */ + async revokeSender(botId, senderKey) { + const config = this.#configStore.get(botId); + if (!config) throw new Error('Unknown DingTalk bot'); + const index = config.approvedSenders.findIndex( + (sender) => sender.senderKey === senderKey, + ); + if (index === -1) throw new Error('Unknown approved DingTalk sender'); + const approvedSenders = [...config.approvedSenders]; + approvedSenders.splice(index, 1); + await this.#saveAndRestart(config, { ...config, approvedSenders }); + return this.status(); + } + + /** Returns browser-safe bot, health, and sender-approval state. */ + status() { + const bots = this.#configStore.list().map((config) => { + const runtime = this.#runtimes.get(config.botId); + let currentStatus = {}; + try { + currentStatus = runtimeStatus(runtime); + } catch { + currentStatus = { state: 'error' }; + } + const connected = isRuntimeConnected(runtime, currentStatus); + const accountError = this.#errors.get(config.botId); + const state = connected ? 'connected' : accountError ? 'error' : 'offline'; + const approvedIds = new Set(config.approvedSenders.map((sender) => sender.staffId)); + const pending = internalPendingSenders(currentStatus) + .filter((sender) => !approvedIds.has(sender.staffId)) + .map(publicPendingSender); + return { + botId: config.botId, + state, + connected, + configured: true, + bot: { + name: '钉钉机器人', + clientIdMasked: maskDingtalkClientId(config.clientId), + }, + health: { + status: connected ? 'healthy' : accountError ? 'error' : 'offline', + summary: connected + ? '钉钉 Stream 消息连接运行正常' + : accountError?.message ?? '钉钉消息连接当前离线', + lastCheckedAt: currentStatus.lastCheckedAt ?? null, + }, + stats: { + messagesReceived: Number(currentStatus.messagesReceived) || 0, + messagesReplied: Number(currentStatus.messagesReplied) || 0, + }, + senders: { + pending, + approved: config.approvedSenders.map(publicApprovedSender), + }, + error: accountError ? structuredClone(accountError) : 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) } + : {}), + }; + } + + /** Cancels provisioning and stops every bot runtime. */ + 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))); + await Promise.allSettled([...this.#transitions.values()]); + await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); + } + + async #pollRegistration(record) { + try { + this.#assertAttemptActive(record); + const response = await this.#deviceAuth.poll({ + deviceCode: record.deviceCode, + signal: record.controller.signal, + }); + this.#assertAttemptActive(record); + const state = cleanString(response.status)?.toUpperCase(); + if (state === 'WAITING') { + record.state = 'pending'; + record.error = null; + } else if (state === 'SUCCESS') { + const clientId = cleanString(response.clientId); + const clientSecret = cleanString(response.clientSecret); + if (!clientId || !clientSecret) throw new Error('DingTalk returned incomplete credentials'); + record.state = 'connecting'; + record.error = null; + this.#touch(); + const activation = await this.#activateBot(record, { clientId, clientSecret }); + record.botId = activation.botId; + record.alreadyConnected = activation.alreadyConnected; + record.state = 'connected'; + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + } else if (state === 'EXPIRED') { + record.state = 'expired'; + record.error = safeError('expired', '二维码已过期,请重新生成。'); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + } else if (state === 'FAIL') { + record.state = 'failed'; + record.error = safeError('authorization-failed', '钉钉未完成机器人授权,请重新扫码。'); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + } else { + record.state = 'pending'; + record.error = safeError('poll-pending', '钉钉授权状态暂时不可用,正在重试。'); + } + } catch (error) { + if (record.controller.signal.aborted || error?.name === 'AbortError') { + record.state = 'cancelled'; + record.error = safeError('cancelled', '扫码接入已取消。'); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + } else if (record.state === 'connecting') { + record.state = 'failed'; + record.error = safeError( + 'activation-failed', + '钉钉已授权,但无法安全保存接入配置。', + ); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#logger.error?.('[dsh-dingtalk] bot activation failed'); + } else { + record.state = 'pending'; + record.error = safeError('poll-failed', '钉钉授权查询暂时失败,正在重试。'); + } + } finally { + this.#touch(); + this.#pruneAttempts(); + } + } + + async #activateBot(record, { clientId, clientSecret }) { + const identity = deriveDingtalkBotIdentity(clientId); + const previousConfig = this.#configStore.getByClientId(clientId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined); + const config = { + botId: identity.botId, + clientId, + secretRef: identity.secretRef, + approvedSenders: previousConfig?.approvedSenders ?? [], + }; + return this.#withBotTransition(identity.botId, async () => { + const rollback = async () => { + await this.#stopRuntime(identity.botId); + if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined); + else if (this.#configStore.get(identity.botId)) { + await this.#configStore.remove(identity.botId).catch(() => undefined); + } + await this.#restoreCredential(identity.secretRef, previousSecret); + if (previousConfig && cleanString(previousSecret?.value)) { + await this.#startRuntime(previousConfig, previousSecret.value).catch(() => undefined); + } + }; + await this.#credentials.set(identity.secretRef, clientSecret); + try { + this.#assertAttemptActive(record); + await this.#configStore.save(config); + this.#assertAttemptActive(record); + } catch (error) { + await rollback(); + throw error; + } + try { + await this.#startRuntime(config, clientSecret); + this.#assertAttemptActive(record); + this.#errors.delete(identity.botId); + } catch (error) { + if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) { + await rollback(); + throw abortError(); + } + this.#errors.set( + identity.botId, + safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'), + ); + this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready'); + } + return { botId: identity.botId, alreadyConnected: Boolean(previousConfig) }; + }); + } + + async #saveAndRestart(previousConfig, nextConfig) { + return this.#withBotTransition(previousConfig.botId, async () => { + const clientSecret = await this.#resolveSecret(previousConfig.secretRef); + if (!clientSecret) throw new Error('The DingTalk client secret is missing'); + await this.#configStore.save(nextConfig); + try { + await this.#startRuntime(nextConfig, clientSecret); + this.#errors.delete(previousConfig.botId); + } catch (error) { + await this.#configStore.save(previousConfig).catch(() => undefined); + await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined); + this.#errors.set( + previousConfig.botId, + safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'), + ); + throw error; + } finally { + this.#touch(); + } + }); + } + + async #startRuntime(config, clientSecret) { + if (this.#closed) throw abortError(); + await this.#stopRuntime(config.botId); + if (this.#closed) throw abortError(); + const runtime = await this.#createRuntime({ + botId: config.botId, + config: structuredClone(config), + clientSecret, + }); + if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') { + throw new TypeError('createRuntime returned an invalid DingTalk runtime'); + } + if (this.#closed) { + await runtime.stop().catch(() => undefined); + throw abortError(); + } + this.#runtimes.set(config.botId, runtime); + try { + await runtime.start(); + if (this.#closed) { + await runtime.stop().catch(() => undefined); + throw abortError(); + } + } catch (error) { + if (this.#runtimes.get(config.botId) === runtime) this.#runtimes.delete(config.botId); + await runtime.stop().catch(() => undefined); + throw error; + } + } + + async #stopRuntime(botId) { + const runtime = this.#runtimes.get(botId); + this.#runtimes.delete(botId); + await runtime?.stop().catch(() => { + this.#logger.warn?.(`[dsh-dingtalk] bot ${botId} failed to stop cleanly`); + }); + } + + async #resolveSecret(secretRef) { + const result = await this.#credentials.resolve(secretRef).catch(() => undefined); + return cleanString(result?.value); + } + + async #restoreCredential(secretRef, previous) { + try { + if (cleanString(previous?.value)) await this.#credentials.set(secretRef, previous.value); + else await this.#credentials.unset(secretRef); + } catch { + this.#logger.error?.(`[dsh-dingtalk] failed to restore credential ${secretRef}`); + } + } + + #assertAttemptActive(record) { + if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) throw abortError(); + } + + #withBotTransition(botId, operation) { + if (this.#closed) return Promise.reject(new Error('dsh-dingtalk controller is closed')); + 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; + } + + #pruneAttempts() { + for (const [id, record] of this.#attempts) { + if (id !== this.#activeAttemptId + && TERMINAL_ATTEMPT_STATES.has(record.state) + && this.#attempts.size > 16) { + this.#attempts.delete(id); + } + } + } + + #touch() { + this.#revision += 1; + } +} + +export { DingtalkController as DingTalkController }; diff --git a/src/channels/dingtalk/dingtalk-runtime.mjs b/src/channels/dingtalk/dingtalk-runtime.mjs new file mode 100644 index 0000000..f0e414b --- /dev/null +++ b/src/channels/dingtalk/dingtalk-runtime.mjs @@ -0,0 +1,358 @@ +import { createDingtalkApi } from './dingtalk-api.mjs'; +import { + createDingtalkBridgeStatus, + DingtalkHarnessBridge, +} from './dingtalk-bridge.mjs'; + +function nonEmptyString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function approvedSenderIds(config) { + const entries = Array.isArray(config?.approvedSenders) + ? config.approvedSenders + : config?.approvedSenders instanceof Set + ? [...config.approvedSenders] + : []; + return new Set(entries.map((entry) => nonEmptyString( + typeof entry === 'string' ? entry : entry?.staffId, + )).filter(Boolean)); +} + +function approvedSenderCount(config) { + return approvedSenderIds(config).size; +} + +function streamIsOpen(client) { + return client?.connected === true || client?.socket?.readyState === 1; +} + +function abortable(promise, signal) { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + Promise.resolve(promise).then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + +async function waitForStreamOpen(client, pollIntervalMs, signal) { + while (true) { + signal?.throwIfAborted(); + if (streamIsOpen(client)) return; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, pollIntervalMs); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); + } +} + +async function connectStream(client, timeoutMs, pollIntervalMs, signal) { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const connectSignal = AbortSignal.any([signal, timeoutSignal]); + let connectSettled = false; + const connectTask = Promise.resolve() + .then(() => client.connect()) + .finally(() => { connectSettled = true; }); + try { + await abortable(connectTask, connectSignal); + await waitForStreamOpen(client, pollIntervalMs, connectSignal); + } catch (error) { + if (connectSignal.aborted) { + if (!connectSettled) { + void connectTask.then(() => client.disconnect()).catch(() => undefined); + } + if (signal.aborted) throw signal.reason; + throw new Error(`DingTalk Stream handshake timed out after ${timeoutMs}ms`); + } + throw error; + } +} + +async function defaultStreamFactory({ clientId, clientSecret }) { + const { DWClient, TOPIC_ROBOT } = await import('dingtalk-stream'); + return { + client: new DWClient({ + clientId, + clientSecret, + endpoint: 'https://api.dingtalk.com', + autoReconnect: false, + keepAlive: true, + debug: false, + }), + topic: TOPIC_ROBOT, + }; +} + +export function createDingtalkRuntimeStatus({ + pendingSenders = [], + approvedSenders = 0, +} = {}) { + return { + startedAt: null, + ready: false, + dingtalkStreamState: 'idle', + harnessReachable: false, + lastConnectedAt: null, + lastCheckedAt: null, + lastCallbackAt: null, + authorizationMode: 'sender-staff-id-approval', + approvedSenderCount: approvedSenders, + ...createDingtalkBridgeStatus({ pendingSenders }), + }; +} + +export class DingtalkRuntime { + #config; + #clientSecret; + #harness; + #state; + #logger; + #replyTimeoutMs; + #maxMessageChars; + #connectTimeoutMs; + #connectPollIntervalMs; + #api; + #streamFactory; + #status; + #client = null; + #bridge = null; + #topic = null; + #starting = null; + #connectionMonitor = null; + #abortController = null; + #callbackTasks = new Set(); + + constructor({ + config, + clientSecret, + harness, + state, + logger = console, + replyTimeoutMs = 600_000, + maxMessageChars = 4_000, + connectTimeoutMs = 15_000, + connectPollIntervalMs = 25, + api = createDingtalkApi(), + streamFactory = defaultStreamFactory, + }) { + if (!config || !nonEmptyString(config.clientId) || !nonEmptyString(clientSecret)) { + throw new TypeError('DingtalkRuntime requires app credentials'); + } + if (!harness || !state) throw new TypeError('DingtalkRuntime requires Harness and state'); + if (typeof streamFactory !== 'function') throw new TypeError('streamFactory must be a function'); + this.#config = config; + this.#clientSecret = clientSecret.trim(); + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#maxMessageChars = maxMessageChars; + this.#connectTimeoutMs = connectTimeoutMs; + this.#connectPollIntervalMs = connectPollIntervalMs; + this.#api = api; + this.#streamFactory = streamFactory; + this.#status = createDingtalkRuntimeStatus({ + pendingSenders: this.#pendingSenders(), + approvedSenders: approvedSenderCount(config), + }); + } + + get status() { + if (this.#bridge) { + const bridgeStatus = this.#bridge.status; + Object.assign(this.#status, bridgeStatus); + } else { + this.#status.pendingSenders = this.#pendingSenders(); + } + return structuredClone(this.#status); + } + + pendingSender(requestId) { + return typeof this.#state.pendingSender === 'function' + ? this.#state.pendingSender(requestId) + : null; + } + + pendingSenders() { + return this.#pendingSenders(); + } + + async start() { + if (this.#client && this.#status.ready) return this.status; + if (this.#starting) return this.#starting; + this.#starting = this.#start().finally(() => { + this.#starting = null; + }); + return this.#starting; + } + + async #start() { + await this.stop(); + const abortController = new AbortController(); + this.#abortController = abortController; + const { signal } = abortController; + this.#status.startedAt = new Date().toISOString(); + this.#status.dingtalkStreamState = 'connecting'; + this.#status.lastError = null; + + try { + await this.#harness.ensureRunning({ signal }); + this.#status.harnessReachable = true; + if (typeof this.#state.removePendingSenderByStaffId === 'function') { + for (const staffId of approvedSenderIds(this.#config)) { + await this.#state.removePendingSenderByStaffId(staffId); + } + this.#status.pendingSenders = this.#pendingSenders(); + } + this.#bridge = new DingtalkHarnessBridge({ + api: this.#api, + clientId: this.#config.clientId, + clientSecret: this.#clientSecret, + approvedSenders: this.#config.approvedSenders, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs, + maxMessageChars: this.#maxMessageChars, + signal, + }); + + const created = await this.#streamFactory({ + clientId: this.#config.clientId, + clientSecret: this.#clientSecret, + }); + signal.throwIfAborted(); + this.#client = created?.client ?? created; + this.#topic = created?.topic ?? created?.TOPIC_ROBOT ?? '/v1.0/im/bot/messages/get'; + if (!this.#client + || typeof this.#client.registerCallbackListener !== 'function' + || typeof this.#client.connect !== 'function' + || typeof this.#client.disconnect !== 'function' + || typeof this.#client.socketCallBackResponse !== 'function') { + throw new TypeError('streamFactory returned an invalid DingTalk Stream client'); + } + + const client = this.#client; + const bridge = this.#bridge; + client.registerCallbackListener(this.#topic, (response) => { + if (this.#client !== client || this.#bridge !== bridge) return; + const callbackMessageId = nonEmptyString(response?.headers?.messageId); + if (callbackMessageId) { + try { + client.socketCallBackResponse(callbackMessageId, { success: true }); + } catch { + this.#logger.warn?.('[dsh-dingtalk] unable to acknowledge an inbound callback'); + } + } + + const task = Promise.resolve().then(async () => { + if (this.#bridge !== bridge) return; + let message; + try { + message = typeof response?.data === 'string' + ? JSON.parse(response.data) + : response?.data; + } catch { + this.#status.lastError = '钉钉消息格式无效。'; + this.#logger.warn?.('[dsh-dingtalk] ignored an invalid callback payload'); + return; + } + if (!message || typeof message !== 'object') return; + this.#status.lastCallbackAt = Date.now(); + await bridge.accept(message); + }).catch(() => { + if (signal.aborted || this.#bridge !== bridge) return; + this.#status.lastError = '钉钉消息处理失败。'; + this.#logger.error?.('[dsh-dingtalk] callback processing failed'); + }).finally(() => this.#callbackTasks.delete(task)); + this.#callbackTasks.add(task); + }); + + await connectStream( + client, + this.#connectTimeoutMs, + this.#connectPollIntervalMs, + signal, + ); + this.#status.ready = true; + this.#status.dingtalkStreamState = 'connected'; + this.#status.lastConnectedAt = Date.now(); + this.#status.lastCheckedAt = Date.now(); + this.#status.lastError = null; + this.#connectionMonitor = setInterval(() => { + const connected = streamIsOpen(client); + this.#status.ready = connected; + this.#status.dingtalkStreamState = connected ? 'connected' : 'reconnecting'; + this.#status.lastCheckedAt = Date.now(); + if (connected) this.#status.lastError = null; + }, 1_000); + this.#connectionMonitor.unref?.(); + return this.status; + } catch (error) { + const aborted = signal.aborted; + this.#status.ready = false; + this.#status.dingtalkStreamState = aborted ? 'idle' : 'failed'; + this.#status.lastError = aborted ? null : (error?.message ?? String(error)); + await this.stop({ preserveError: !aborted }); + throw error; + } + } + + async stop({ preserveError = false } = {}) { + const lastError = preserveError ? this.#status.lastError : null; + const abortController = this.#abortController; + this.#abortController = null; + abortController?.abort(new DOMException('DingTalk runtime stopped', 'AbortError')); + if (this.#connectionMonitor) clearInterval(this.#connectionMonitor); + this.#connectionMonitor = null; + this.#status.ready = false; + + const client = this.#client; + this.#client = null; + this.#topic = null; + if (client) { + try { + await client.disconnect(); + } catch { + this.#logger.warn?.('[dsh-dingtalk] DingTalk Stream disconnect failed'); + } + } + await Promise.allSettled([...this.#callbackTasks]); + this.#callbackTasks.clear(); + if (this.#bridge) await this.#bridge.waitForIdle(); + this.#bridge = null; + this.#status.dingtalkStreamState = preserveError ? 'failed' : 'idle'; + this.#status.lastError = lastError; + return this.status; + } + + #pendingSenders() { + return typeof this.#state.pendingSenders === 'function' + ? this.#state.pendingSenders() + : []; + } +} + +export const DingTalkRuntime = DingtalkRuntime; +export const createDingTalkRuntimeStatus = createDingtalkRuntimeStatus; diff --git a/src/channels/dingtalk/harness-client.mjs b/src/channels/dingtalk/harness-client.mjs new file mode 100644 index 0000000..ac9b548 --- /dev/null +++ b/src/channels/dingtalk/harness-client.mjs @@ -0,0 +1,299 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; + +function sleep(ms, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +function assistantMessageText(event) { + return (event?.data?.message?.content ?? []) + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text) + .join('\n') + .trim(); +} + +export class HarnessReplyTracker { + #promptRpcId; + #lastSeq; + #openTurn = null; + #targetTurn = null; + #stepText = new Map(); + #latestText = ''; + #finished = false; + #reason = null; + + constructor({ promptRpcId, afterSeq = -1 }) { + this.#promptRpcId = promptRpcId; + this.#lastSeq = afterSeq; + } + + get finished() { + return this.#finished; + } + + get answer() { + return this.#latestText.trim(); + } + + get reason() { + return this.#reason; + } + + consume(entries) { + let update = null; + const ordered = [...entries] + .map((entry) => entry?.event ?? entry) + .filter(Boolean) + .sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1)); + + for (const event of ordered) { + const seq = event.seq ?? -1; + if (seq <= this.#lastSeq) continue; + this.#lastSeq = seq; + + if (event.type === 'turn/start') this.#openTurn = event.data?.turn ?? null; + + if (event.type === 'user/message' && event.data?.source?.rpcId === this.#promptRpcId) { + this.#targetTurn = this.#openTurn; + continue; + } + if (this.#targetTurn === null) continue; + + if (event.type === 'turn/end') { + if (event.data?.turn !== this.#targetTurn) continue; + this.#finished = true; + this.#reason = event.data?.reason ?? null; + this.#openTurn = null; + continue; + } + if (event.data?.turn !== this.#targetTurn) continue; + + if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') { + const step = event.data?.step ?? 0; + const index = event.data.chunk.index ?? 0; + const key = `${step}:${index}`; + this.#stepText.set(key, (this.#stepText.get(key) ?? '') + event.data.chunk.text); + const prefix = `${step}:`; + const text = [...this.#stepText.entries()] + .filter(([partKey]) => partKey.startsWith(prefix)) + .sort(([left], [right]) => Number(left.split(':')[1]) - Number(right.split(':')[1])) + .map(([, part]) => part) + .join('\n') + .trim(); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: 'text', text }; + } + continue; + } + + if (event.type === 'assistant/message') { + const text = assistantMessageText(event); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: 'text', text }; + } + continue; + } + + if (event.type === 'tool/call') { + update = { type: 'tool', name: event.data?.name ?? '工具' }; + } else if (event.type === 'tool/result') { + update = { type: 'status', text: '正在整理结果…' }; + } + } + return update; + } +} + +export class HarnessRpcError extends Error { + constructor(method, error) { + super(`${method}: ${error?.message ?? 'unknown Harness RPC error'}`); + this.name = 'HarnessRpcError'; + this.method = method; + this.code = error?.code ?? 'internal'; + this.details = error?.details ?? {}; + } +} + +export class HarnessClient { + #baseUrl; + #workspace; + #agentPreset; + #autostart; + #dshBin; + #fetch; + #managedProcess = null; + + constructor({ + baseUrl, + workspace, + agentPreset = 'standard', + autostart = false, + dshBin = 'dsh', + fetchImpl = fetch, + }) { + this.#baseUrl = new URL(baseUrl); + this.#workspace = workspace; + this.#agentPreset = agentPreset; + this.#autostart = autostart; + this.#dshBin = dshBin; + this.#fetch = fetchImpl; + } + + async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) { + const rpcId = options.rpcId ?? `dingtalk-${randomUUID()}`; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signal = options.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + const response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId, method, payload }), + signal, + }); + if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`); + const body = await response.json(); + if (body?.type !== 'server-response' || body?.rpcId !== rpcId) { + throw new Error(`Harness returned an invalid response for ${method}`); + } + if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error); + return body.result.value; + } + + async health(options = {}) { + await this.rpc('host.describe', {}, 5_000, options); + return true; + } + + async ensureRunning(options = {}) { + try { + return await this.health(options); + } catch (firstError) { + if (!this.#autostart) throw firstError; + } + + if (!this.#managedProcess || this.#managedProcess.exitCode !== null) { + const port = this.#baseUrl.port || (this.#baseUrl.protocol === 'https:' ? '443' : '80'); + this.#managedProcess = spawn(this.#dshBin, [ + 'web', '--host', this.#baseUrl.hostname, '--port', port, + ], { + cwd: this.#workspace, + env: process.env, + stdio: ['ignore', 'inherit', 'inherit'], + }); + this.#managedProcess.on('error', (error) => { + console.error('[dsh-dingtalk] failed to start Harness:', error.message); + }); + } + + const deadline = Date.now() + 60_000; + let lastError; + while (Date.now() < deadline) { + await sleep(1_000, options.signal); + try { + return await this.health(options); + } catch (error) { + lastError = error; + } + } + throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`); + } + + async workspaceId(options = {}) { + const { items } = await this.rpc('workspace.list', {}, 30_000, options); + const existing = items.find((item) => item.path === this.#workspace); + if (existing) return existing.workspaceId; + const created = await this.rpc('workspace.create', { path: this.#workspace }, 30_000, options); + return created.workspace.workspaceId; + } + + async createSession(options = {}) { + await this.ensureRunning(options); + const workspaceId = await this.workspaceId(options); + const created = await this.rpc('session.create', { + workspaceId, + agentPreset: this.#agentPreset, + }, 30_000, options); + return created.sessionId; + } + + async sessionExists(sessionId, options = {}) { + try { + await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options); + return true; + } catch (error) { + if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false; + throw error; + } + } + + async ask(sessionId, text, options = {}) { + if (typeof options === 'number') options = { timeoutMs: options }; + const timeoutMs = options.timeoutMs ?? 600_000; + const signal = options.signal; + const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null; + await this.ensureRunning({ signal }); + const before = await this.rpc( + 'session.history', + { sessionId, maxMessages: 1 }, + 30_000, + { signal }, + ); + const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1)); + const promptRpcId = `dingtalk-${randomUUID()}`; + const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq }); + + await this.rpc('session.prompt', { + sessionId, + mode: 'queue', + content: [{ type: 'text', text }], + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, 30_000, { rpcId: promptRpcId, signal }); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(300, signal); + const history = await this.rpc( + 'session.history', + { sessionId, maxMessages: 50 }, + 30_000, + { signal }, + ); + const update = tracker.consume(history.events ?? []); + if (update && onUpdate) { + try { + await onUpdate(update); + } catch (error) { + console.warn('[dsh-dingtalk] ignored a progress update failure:', error.message); + } + } + if (!tracker.finished) continue; + if (tracker.answer) return tracker.answer; + throw new Error( + `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`, + ); + } + throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`); + } + + stopManagedProcess() { + if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM'); + } +} diff --git a/src/channels/dingtalk/state-store.mjs b/src/channels/dingtalk/state-store.mjs new file mode 100644 index 0000000..f109639 --- /dev/null +++ b/src/channels/dingtalk/state-store.mjs @@ -0,0 +1,212 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const EMPTY_STATE = Object.freeze({ + version: 1, + sessions: {}, + seenMessageIds: [], + pendingSenders: {}, +}); + +function nonEmptyString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function displayName(value) { + return (nonEmptyString(value) ?? '钉钉用户').slice(0, 100); +} + +function normalizePendingSender(value, fallbackRequestId) { + if (!value || typeof value !== 'object') return null; + const requestId = nonEmptyString(value.requestId) ?? nonEmptyString(fallbackRequestId); + const staffId = nonEmptyString(value.staffId); + const requestedAt = nonEmptyString(value.requestedAt) ?? nonEmptyString(value.lastSeenAt); + const lastSeenAt = nonEmptyString(value.lastSeenAt) ?? requestedAt; + if (!requestId || !staffId || !requestedAt || !lastSeenAt) return null; + return { + requestId, + staffId, + displayName: displayName(value.displayName ?? value.nick), + requestedAt, + lastSeenAt, + }; +} + +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)) { + const normalizedKey = nonEmptyString(key); + const normalizedSession = nonEmptyString(sessionId); + if (normalizedKey && normalizedSession) sessions[normalizedKey] = normalizedSession; + } + } + + const pendingSenders = {}; + const entries = Array.isArray(value.pendingSenders) + ? value.pendingSenders.map((entry) => [entry?.requestId, entry]) + : Object.entries(value.pendingSenders && typeof value.pendingSenders === 'object' + ? value.pendingSenders + : {}); + for (const [key, candidate] of entries) { + const pending = normalizePendingSender(candidate, key); + if (!pending) continue; + const duplicate = Object.values(pendingSenders).find((entry) => entry.staffId === pending.staffId); + if (!duplicate || duplicate.lastSeenAt < pending.lastSeenAt) { + if (duplicate) delete pendingSenders[duplicate.requestId]; + pendingSenders[pending.requestId] = pending; + } + } + + return { + version: 1, + sessions, + seenMessageIds: Array.isArray(value.seenMessageIds) + ? [...new Set(value.seenMessageIds.map(nonEmptyString).filter(Boolean))].slice(-1_000) + : [], + pendingSenders, + }; +} + +export class DingtalkStateStore { + #path; + #state = structuredClone(EMPTY_STATE); + #writeQueue = Promise.resolve(); + #idFactory; + #now; + + constructor(path, { idFactory = randomUUID, now = () => new Date().toISOString() } = {}) { + if (!nonEmptyString(path)) throw new TypeError('state path is required'); + if (typeof idFactory !== 'function' || typeof now !== 'function') { + throw new TypeError('idFactory and now must be functions'); + } + this.#path = path; + this.#idFactory = idFactory; + this.#now = now; + } + + 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) { + const normalizedKey = nonEmptyString(key); + const normalizedSession = nonEmptyString(sessionId); + if (!normalizedKey || !normalizedSession) throw new TypeError('key and sessionId are required'); + this.#state.sessions[normalizedKey] = normalizedSession; + await this.#persist(); + } + + async clearSession(key) { + const normalizedKey = nonEmptyString(key); + if (!normalizedKey || !(normalizedKey in this.#state.sessions)) return; + delete this.#state.sessions[normalizedKey]; + await this.#persist(); + } + + hasSeen(messageId) { + const id = nonEmptyString(messageId); + return Boolean(id && this.#state.seenMessageIds.includes(id)); + } + + async markSeen(messageId) { + const id = nonEmptyString(messageId); + if (!id) throw new TypeError('messageId is required'); + if (this.hasSeen(id)) return; + this.#state.seenMessageIds.push(id); + if (this.#state.seenMessageIds.length > 1_000) { + this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000); + } + await this.#persist(); + } + + pendingSenders() { + return Object.values(this.#state.pendingSenders) + .sort((left, right) => left.requestedAt.localeCompare(right.requestedAt)) + .map((entry) => structuredClone(entry)); + } + + pendingSender(requestId) { + const id = nonEmptyString(requestId); + const entry = id ? this.#state.pendingSenders[id] : null; + return entry ? structuredClone(entry) : null; + } + + async recordPendingSender(staffIdOrEntry, name, seenAt) { + const input = staffIdOrEntry && typeof staffIdOrEntry === 'object' + ? staffIdOrEntry + : { staffId: staffIdOrEntry, displayName: name, lastSeenAt: seenAt }; + const staffId = nonEmptyString(input.staffId); + if (!staffId) throw new TypeError('staffId is required'); + const timestamp = nonEmptyString(input.lastSeenAt) ?? nonEmptyString(input.requestedAt) ?? this.#now(); + const existing = Object.values(this.#state.pendingSenders) + .find((entry) => entry.staffId === staffId); + const entry = { + requestId: existing?.requestId ?? `ding_sender_${this.#idFactory()}`, + staffId, + displayName: displayName(input.displayName ?? input.nick ?? name), + requestedAt: existing?.requestedAt ?? timestamp, + lastSeenAt: timestamp, + }; + this.#state.pendingSenders[entry.requestId] = entry; + await this.#persist(); + return structuredClone(entry); + } + + async removePendingSender(requestId) { + const id = nonEmptyString(requestId); + if (!id || !this.#state.pendingSenders[id]) return false; + delete this.#state.pendingSenders[id]; + await this.#persist(); + return true; + } + + async removePendingSenderByStaffId(staffId) { + const id = nonEmptyString(staffId); + const pending = id + ? Object.values(this.#state.pendingSenders).find((entry) => entry.staffId === id) + : null; + return pending ? this.removePendingSender(pending.requestId) : false; + } + + snapshot() { + return structuredClone(this.#state); + } + + async remove() { + await this.#writeQueue; + 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; + } +} + +export const DingTalkStateStore = DingtalkStateStore; diff --git a/src/channels/feishu/bridge.mjs b/src/channels/feishu/bridge.mjs new file mode 100644 index 0000000..ffa7da6 --- /dev/null +++ b/src/channels/feishu/bridge.mjs @@ -0,0 +1,216 @@ +import { + conversationKey, + extractText, + isAllowedSender, + isBotSender, + splitText, +} from './message-utils.mjs'; + +const HELP_TEXT = [ + '北汇星河 AIOS 已连接 DeepSeek Harness。', + '', + '直接发送问题即可继续当前会话。', + '/new 开启一个全新会话', + '/status 检查连接状态', + '/help 显示本帮助', +].join('\n'); + +export class FeishuHarnessBridge { + #client; + #channel; + #harness; + #state; + #queues = new Map(); + #acceptedMessageIds = new Set(); + #status; + #allowedSenderOpenIds; + #replyTimeoutMs; + + constructor({ + client, + channel, + harness, + state, + status, + allowedSenderOpenIds = new Set(), + replyTimeoutMs = 600000, + }) { + this.#client = client; + this.#channel = channel; + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#allowedSenderOpenIds = allowedSenderOpenIds; + this.#replyTimeoutMs = replyTimeoutMs; + } + + accept(event) { + const messageId = event?.message?.message_id; + if (!messageId || isBotSender(event) || event?.message?.message_type !== 'text') return; + if (!isAllowedSender(event, this.#allowedSenderOpenIds)) { + this.#status.messagesRejected += 1; + this.#status.lastRejectedAt = new Date().toISOString(); + console.warn('[bridge] ignored a message from a sender outside the allowlist'); + return; + } + if (this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) return; + this.#acceptedMessageIds.add(messageId); + const processingReaction = this.#addReaction(messageId, 'OnIt'); + + const key = conversationKey(event); + const previous = this.#queues.get(key) ?? Promise.resolve(); + const task = previous + .catch(() => undefined) + .then(() => this.#handle(event, key)) + .then(() => this.#finishReaction(messageId, processingReaction, 'DONE')) + .catch(async (error) => { + console.error('[bridge] message handling failed:', error.message); + this.#status.lastError = error.message; + await this.#finishReaction(messageId, processingReaction, 'ERROR'); + await this.#send( + event.message.chat_id, + '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。', + ).catch(() => undefined); + }) + .finally(() => { + this.#acceptedMessageIds.delete(messageId); + if (this.#queues.get(key) === task) this.#queues.delete(key); + }); + this.#queues.set(key, task); + } + + async waitForIdle() { + await Promise.allSettled([...this.#queues.values()]); + } + + async #handle(event, key) { + const messageId = event.message.message_id; + await this.#state.markSeen(messageId); + this.#status.lastMessageAt = new Date().toISOString(); + this.#status.messagesReceived += 1; + + const text = extractText(event); + if (!text) return; + + if (text === '/help') { + await this.#send(event.message.chat_id, HELP_TEXT); + return; + } + if (text === '/new') { + await this.#state.clearSession(key); + await this.#send(event.message.chat_id, '已开启全新 Harness 会话。'); + return; + } + if (text === '/status') { + await this.#harness.ensureRunning(); + await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。'); + 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); + } + + console.info(`[bridge] processing ${event.message.chat_type} message ${messageId} in ${sessionId}`); + await this.#answerWithStream(event, sessionId, text); + this.#status.messagesReplied += 1; + this.#status.lastReplyAt = new Date().toISOString(); + this.#status.lastError = null; + } + + async #answerWithStream(event, sessionId, text) { + const chatId = event.message.chat_id; + const messageId = event.message.message_id; + if (!this.#channel?.stream) { + const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs }); + for (const chunk of splitText(answer)) await this.#send(chatId, chunk); + this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1; + return; + } + + let promptStarted = false; + let completedAnswer = ''; + try { + await this.#channel.stream(chatId, { + markdown: async (controller) => { + promptStarted = true; + completedAnswer = await this.#harness.ask(sessionId, text, { + timeoutMs: this.#replyTimeoutMs, + onUpdate: async (update) => { + await controller.setContent(this.#progressText(update)); + this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1; + }, + }); + await controller.setContent(completedAnswer); + }, + }, { replyTo: messageId }); + this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1; + } catch (error) { + this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1; + if (completedAnswer) { + console.warn('[bridge] native Feishu stream failed after generation; sending final text:', error.message); + for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk); + this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1; + return; + } + if (promptStarted) throw error; + + console.warn('[bridge] native Feishu stream unavailable; using text fallback:', error.message); + const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs }); + for (const chunk of splitText(answer)) await this.#send(chatId, chunk); + this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1; + } + } + + #progressText(update) { + if (update.type === 'text' && update.text) return update.text; + if (update.type === 'tool') { + if (update.name === 'web_search') return '_正在搜索网络并整理信息…_'; + return `_正在使用 ${update.name || '工具'}…_`; + } + return `_${update.text || '正在处理…'}_`; + } + + async #addReaction(messageId, emojiType) { + if (!this.#channel?.addReaction) return null; + try { + const reactionId = await this.#channel.addReaction(messageId, emojiType); + this.#status.reactionsAdded = (this.#status.reactionsAdded ?? 0) + 1; + return reactionId; + } catch (error) { + this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1; + console.warn(`[bridge] unable to add ${emojiType} reaction:`, error.message); + return null; + } + } + + async #finishReaction(messageId, processingReaction, finalEmojiType) { + const reactionId = await processingReaction; + if (reactionId && this.#channel?.removeReaction) { + try { + await this.#channel.removeReaction(messageId, reactionId); + this.#status.reactionsRemoved = (this.#status.reactionsRemoved ?? 0) + 1; + } catch (error) { + this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1; + console.warn('[bridge] unable to remove processing reaction:', error.message); + } + } + await this.#addReaction(messageId, finalEmojiType); + } + + async #send(chatId, text) { + const response = await this.#client.im.v1.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'text', + content: JSON.stringify({ text }), + }, + }); + if (response?.code && response.code !== 0) { + throw new Error(`Feishu send failed: ${response.msg || response.code}`); + } + } +} diff --git a/src/channels/feishu/config.mjs b/src/channels/feishu/config.mjs new file mode 100644 index 0000000..26d9685 --- /dev/null +++ b/src/channels/feishu/config.mjs @@ -0,0 +1,71 @@ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +function required(name, value) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Missing required configuration: ${name}`); + } + return value.trim(); +} + +function readSecret(appId) { + if (process.env.FEISHU_APP_SECRET?.trim()) return process.env.FEISHU_APP_SECRET.trim(); + + const service = process.env.FEISHU_SECRET_SERVICE?.trim(); + if (!service) throw new Error('Missing FEISHU_APP_SECRET or FEISHU_SECRET_SERVICE'); + + try { + return execFileSync('/usr/bin/security', [ + 'find-generic-password', + '-a', + appId, + '-s', + service, + '-w', + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + throw new Error(`Unable to read Feishu app secret from macOS Keychain service ${service}`); + } +} + +function bool(name, fallback) { + const raw = process.env[name]; + if (raw === undefined) return fallback; + return !['0', 'false', 'no', 'off'].includes(raw.trim().toLowerCase()); +} + +function csvSet(value) { + return new Set((value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean)); +} + +function requiredCsvSet(name, value) { + const items = csvSet(value); + if (items.size === 0) throw new Error(`Missing required configuration: ${name}`); + return items; +} + +export function loadConfig() { + const appId = required('FEISHU_APP_ID', process.env.FEISHU_APP_ID); + const appSecret = required('FEISHU_APP_SECRET', readSecret(appId)); + const workspace = required('HARNESS_WORKSPACE', process.env.HARNESS_WORKSPACE); + + return Object.freeze({ + appId, + appSecret, + harnessBaseUrl: new URL(process.env.HARNESS_BASE_URL ?? 'http://127.0.0.1:3080'), + harnessWorkspace: resolve(workspace), + harnessAgentPreset: process.env.HARNESS_AGENT_PRESET?.trim() || 'standard', + harnessAutostart: bool('HARNESS_AUTOSTART', true), + dshBin: process.env.DSH_BIN?.trim() || 'dsh', + healthPort: Number.parseInt(process.env.BRIDGE_HEALTH_PORT ?? '3091', 10), + statePath: resolve(process.env.BRIDGE_STATE_PATH ?? './data/state.json'), + replyTimeoutMs: Number.parseInt(process.env.HARNESS_REPLY_TIMEOUT_MS ?? '600000', 10), + allowedSenderOpenIds: requiredCsvSet( + 'FEISHU_ALLOWED_OPEN_IDS', + process.env.FEISHU_ALLOWED_OPEN_IDS, + ), + }); +} diff --git a/src/channels/feishu/feishu-app.mjs b/src/channels/feishu/feishu-app.mjs new file mode 100644 index 0000000..9c6455b --- /dev/null +++ b/src/channels/feishu/feishu-app.mjs @@ -0,0 +1,51 @@ +function endpointFor(domain, path) { + const origin = domain === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn'; + return new URL(path, origin); +} + +async function jsonResponse(response, operation) { + let body; + try { + body = await response.json(); + } catch { + throw new Error(`${operation} returned a non-JSON response`); + } + if (!response.ok || body?.code !== 0) { + throw new Error(`${operation} failed: ${body?.msg || `HTTP ${response.status}`}`); + } + return body; +} + +/** Validate freshly provisioned credentials and read the bot identity. */ +export async function verifyFeishuApp({ + appId, + appSecret, + domain = 'feishu', + fetchImpl = fetch, + timeoutMs = 15000, +}) { + if (!appId || !appSecret) throw new Error('Feishu credentials are incomplete'); + const tokenResponse = await fetchImpl(endpointFor(domain, '/open-apis/auth/v3/tenant_access_token/internal'), { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ app_id: appId, app_secret: appSecret }), + signal: AbortSignal.timeout(timeoutMs), + }); + const tokenBody = await jsonResponse(tokenResponse, 'Feishu authentication'); + if (!tokenBody.tenant_access_token) { + throw new Error('Feishu authentication returned no tenant access token'); + } + + const botResponse = await fetchImpl(endpointFor(domain, '/open-apis/bot/v3/info/'), { + headers: { authorization: `Bearer ${tokenBody.tenant_access_token}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + const botBody = await jsonResponse(botResponse, 'Feishu bot verification'); + const bot = botBody.bot ?? {}; + return Object.freeze({ + appId, + name: bot.app_name ?? bot.bot_name ?? null, + openId: bot.open_id ?? null, + activated: bot.activate_status ?? null, + }); +} diff --git a/src/channels/feishu/feishu-channel.mjs b/src/channels/feishu/feishu-channel.mjs new file mode 100644 index 0000000..b7aa335 --- /dev/null +++ b/src/channels/feishu/feishu-channel.mjs @@ -0,0 +1,153 @@ +const STREAM_ELEMENT_ID = 'stream_md'; +const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…'; +const MAX_STREAM_CHARS = 28000; + +function assertApiSuccess(operation, response) { + if (response?.code && response.code !== 0) { + throw new Error(`${operation} failed: ${response.msg || response.code}`); + } + return response; +} + +function summaryOf(text) { + const summary = String(text ?? '').replace(/\s+/g, ' ').trim(); + return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`; +} + +function streamingCard(initialText) { + return { + schema: '2.0', + config: { + streaming_mode: true, + summary: { content: '正在生成…' }, + streaming_config: { + print_frequency_ms: { default: 70 }, + print_step: { default: 1 }, + print_strategy: 'fast', + }, + }, + body: { + elements: [{ + tag: 'markdown', + element_id: STREAM_ELEMENT_ID, + content: initialText, + }], + }, + }; +} + +export class VerifiedFeishuChannel { + #client; + #initialText; + + constructor({ client, initialText = DEFAULT_INITIAL_TEXT }) { + this.#client = client; + this.#initialText = initialText; + } + + async stream(chatId, input, options = {}) { + if (typeof input?.markdown !== 'function') { + throw new Error('Feishu stream requires a markdown producer'); + } + + let messageId = null; + const cardResponse = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({ + data: { + type: 'card_json', + data: JSON.stringify(streamingCard(this.#initialText)), + }, + })); + const cardId = cardResponse?.data?.card_id; + if (!cardId) throw new Error('Feishu card.create returned no card_id'); + + try { + messageId = await this.#sendCard(chatId, cardId, options.replyTo); + let sequence = 0; + let lastContent = this.#initialText; + const controller = { + messageId, + setContent: async (content) => { + const next = String(content ?? '') || '…'; + if (next === lastContent) return; + if (next.length > MAX_STREAM_CHARS) { + throw new Error(`Feishu stream content exceeds ${MAX_STREAM_CHARS} characters`); + } + const response = await this.#client.cardkit.v1.cardElement.content({ + path: { card_id: cardId, element_id: STREAM_ELEMENT_ID }, + data: { + content: next, + sequence: ++sequence, + uuid: `content_${cardId}_${sequence}`, + }, + }); + assertApiSuccess('Feishu cardElement.content', response); + lastContent = next; + }, + }; + + await input.markdown(controller); + const finishResponse = await this.#client.cardkit.v1.card.settings({ + path: { card_id: cardId }, + data: { + settings: JSON.stringify({ + config: { + streaming_mode: false, + summary: { content: summaryOf(lastContent) || '回答完成' }, + }, + }), + sequence: ++sequence, + uuid: `settings_${cardId}_${sequence}`, + }, + }); + assertApiSuccess('Feishu card.settings', finishResponse); + return { messageId }; + } catch (error) { + if (messageId) await this.#recall(messageId); + throw error; + } + } + + async #sendCard(chatId, cardId, replyTo) { + const content = JSON.stringify({ type: 'card', data: { card_id: cardId } }); + const response = replyTo + ? await this.#client.im.v1.message.reply({ + path: { message_id: replyTo }, + data: { msg_type: 'interactive', content }, + }) + : await this.#client.im.v1.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { receive_id: chatId, msg_type: 'interactive', content }, + }); + assertApiSuccess('Feishu message send', response); + const messageId = response?.data?.message_id; + if (!messageId) throw new Error('Feishu message send returned no message_id'); + return messageId; + } + + async #recall(messageId) { + try { + const response = await this.#client.im.v1.message.delete({ + path: { message_id: messageId }, + }); + assertApiSuccess('Feishu message delete', response); + } catch (error) { + console.warn('[bridge] unable to recall a failed streaming card:', error.message); + } + } + + async addReaction(messageId, emojiType) { + const response = assertApiSuccess('Feishu reaction.create', await this.#client.im.v1.messageReaction.create({ + path: { message_id: messageId }, + data: { reaction_type: { emoji_type: emojiType } }, + })); + const reactionId = response?.data?.reaction_id; + if (!reactionId) throw new Error('Feishu reaction.create returned no reaction_id'); + return reactionId; + } + + async removeReaction(messageId, reactionId) { + assertApiSuccess('Feishu reaction.delete', await this.#client.im.v1.messageReaction.delete({ + path: { message_id: messageId, reaction_id: reactionId }, + })); + } +} diff --git a/src/channels/feishu/feishu-runtime.mjs b/src/channels/feishu/feishu-runtime.mjs new file mode 100644 index 0000000..f71d9e4 --- /dev/null +++ b/src/channels/feishu/feishu-runtime.mjs @@ -0,0 +1,222 @@ +import { FeishuHarnessBridge } from './bridge.mjs'; +import { VerifiedFeishuChannel } from './feishu-channel.mjs'; + +export function createBridgeStatus({ allowedSenderCount = 1 } = {}) { + return { + startedAt: null, + ready: false, + feishuLongConnectionState: 'idle', + harnessReachable: false, + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + reactionsAdded: 0, + reactionsRemoved: 0, + reactionErrors: 0, + streamResponses: 0, + streamUpdates: 0, + streamFallbacks: 0, + streamErrors: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null, + agentPreset: 'standard', + authorizationMode: 'sender-open-id-allowlist', + allowedSenderCount, + }; +} + +/** + * Owns one live Feishu long connection and the already-tested bridge stack. + * The class intentionally receives the SDK and Harness dependencies so the + * plugin can run it in-process while tests exercise the lifecycle without a + * real Feishu tenant. + */ +export class FeishuRuntime { + #lark; + #appId; + #appSecret; + #domain; + #ownerOpenIds; + #harness; + #state; + #replyTimeoutMs; + #connectTimeoutMs; + #logger; + #client = null; + #bridge = null; + #wsClient = null; + #starting = null; + #status; + + constructor({ + lark, + appId, + appSecret, + domain = 'feishu', + ownerOpenId, + ownerOpenIds, + harness, + state, + replyTimeoutMs = 600000, + connectTimeoutMs = 15000, + logger = console, + }) { + if (!lark) throw new Error('FeishuRuntime requires the Feishu SDK'); + if (!appId || !appSecret) throw new Error('FeishuRuntime requires app credentials'); + const allowedOwners = Array.isArray(ownerOpenIds) ? ownerOpenIds : [ownerOpenId]; + const normalizedOwners = [...new Set(allowedOwners.filter((value) => typeof value === 'string' && value))]; + if (normalizedOwners.length === 0) throw new Error('FeishuRuntime requires at least one owner open_id'); + if (!harness) throw new Error('FeishuRuntime requires a Harness client'); + if (!state) throw new Error('FeishuRuntime requires a state store'); + + this.#lark = lark; + this.#appId = appId; + this.#appSecret = appSecret; + this.#domain = domain; + this.#ownerOpenIds = normalizedOwners; + this.#harness = harness; + this.#state = state; + this.#replyTimeoutMs = replyTimeoutMs; + this.#connectTimeoutMs = connectTimeoutMs; + this.#logger = logger; + this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length }); + } + + get status() { + return structuredClone(this.#status); + } + + async start() { + if (this.#wsClient && this.#status.ready) return this.status; + if (this.#starting) return this.#starting; + + this.#starting = this.#start().finally(() => { + this.#starting = null; + }); + return this.#starting; + } + + async #start() { + this.#status.startedAt = new Date().toISOString(); + this.#status.feishuLongConnectionState = 'connecting'; + this.#status.lastError = null; + + try { + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + + const sdkDomain = this.#domain === 'lark' + ? this.#lark.Domain.Lark + : this.#lark.Domain.Feishu; + const larkConfig = { + appId: this.#appId, + appSecret: this.#appSecret, + domain: sdkDomain, + }; + this.#client = new this.#lark.Client(larkConfig); + const channel = new VerifiedFeishuChannel({ + client: this.#client, + initialText: '已连接 DeepSeek Harness,正在思考…', + }); + this.#bridge = new FeishuHarnessBridge({ + client: this.#client, + channel, + harness: this.#harness, + state: this.#state, + status: this.#status, + allowedSenderOpenIds: new Set(this.#ownerOpenIds), + replyTimeoutMs: this.#replyTimeoutMs, + }); + + const dispatcher = new this.#lark.EventDispatcher({}).register({ + 'im.message.receive_v1': (event) => { + this.#bridge.accept(event); + return {}; + }, + 'im.message.reaction.created_v1': () => ({}), + 'im.message.reaction.deleted_v1': () => ({}), + }); + + let settleReady; + let settleError; + const ready = new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error(`Feishu WebSocket handshake timed out after ${this.#connectTimeoutMs}ms`)); + }, this.#connectTimeoutMs); + settleReady = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(); + }; + settleError = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }; + }); + + this.#wsClient = new this.#lark.WSClient({ + ...larkConfig, + loggerLevel: this.#lark.LoggerLevel.info, + handshakeTimeoutMs: 15000, + onReady: () => { + this.#status.feishuLongConnectionState = 'connected'; + this.#status.ready = true; + this.#status.lastError = null; + settleReady(); + }, + onError: (error) => { + this.#status.feishuLongConnectionState = 'failed'; + this.#status.ready = false; + this.#status.lastError = error?.message ?? String(error); + this.#logger.error('[dsh-feishu] Feishu long connection failed:', this.#status.lastError); + settleError(error); + }, + onReconnecting: () => { + this.#status.feishuLongConnectionState = 'reconnecting'; + this.#status.ready = false; + }, + onReconnected: () => { + this.#status.feishuLongConnectionState = 'connected'; + this.#status.ready = true; + this.#status.lastError = null; + }, + }); + await this.#wsClient.start({ eventDispatcher: dispatcher }).catch((error) => { + settleError(error); + }); + await ready; + return this.status; + } catch (error) { + this.#status.ready = false; + this.#status.feishuLongConnectionState = 'failed'; + this.#status.lastError = error?.message ?? String(error); + await this.stop({ preserveError: true }); + throw error; + } + } + + async stop({ preserveError = false } = {}) { + const error = preserveError ? this.#status.lastError : null; + this.#status.ready = false; + if (this.#wsClient) { + this.#wsClient.close({ force: true }); + this.#wsClient = null; + } + if (this.#bridge) { + await this.#bridge.waitForIdle(); + this.#bridge = null; + } + this.#client = null; + this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle'; + this.#status.lastError = error; + return this.status; + } +} diff --git a/src/channels/feishu/harness-client.mjs b/src/channels/feishu/harness-client.mjs new file mode 100644 index 0000000..310b224 --- /dev/null +++ b/src/channels/feishu/harness-client.mjs @@ -0,0 +1,268 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, 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(); +} + +export class HarnessReplyTracker { + #promptRpcId; + #lastSeq; + #openTurn = null; + #targetTurn = null; + #stepText = new Map(); + #latestText = ''; + #finished = false; + #reason = null; + + constructor({ promptRpcId, afterSeq = -1 }) { + this.#promptRpcId = promptRpcId; + this.#lastSeq = afterSeq; + } + + get finished() { + return this.#finished; + } + + get answer() { + return this.#latestText.trim(); + } + + get reason() { + return this.#reason; + } + + consume(entries) { + let update = null; + const ordered = [...entries] + .map((entry) => entry?.event ?? entry) + .filter(Boolean) + .sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1)); + + for (const event of ordered) { + const seq = event.seq ?? -1; + if (seq <= this.#lastSeq) continue; + this.#lastSeq = seq; + + if (event.type === 'turn/start') { + this.#openTurn = event.data?.turn ?? null; + } + + if (event.type === 'user/message' + && event.data?.source?.rpcId === this.#promptRpcId) { + this.#targetTurn = this.#openTurn; + continue; + } + + if (this.#targetTurn === null) continue; + + if (event.type === 'turn/end') { + if (event.data?.turn !== this.#targetTurn) continue; + this.#finished = true; + this.#reason = event.data?.reason ?? null; + this.#openTurn = null; + continue; + } + + if (event.data?.turn !== this.#targetTurn) continue; + + if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') { + const step = event.data?.step ?? 0; + const index = event.data.chunk.index ?? 0; + const key = `${step}:${index}`; + this.#stepText.set(key, (this.#stepText.get(key) ?? '') + event.data.chunk.text); + const stepPrefix = `${step}:`; + const text = [...this.#stepText.entries()] + .filter(([partKey]) => partKey.startsWith(stepPrefix)) + .sort(([left], [right]) => Number(left.split(':')[1]) - Number(right.split(':')[1])) + .map(([, part]) => part) + .join('\n') + .trim(); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: 'text', text }; + } + continue; + } + + if (event.type === 'assistant/message') { + const text = messageText(event); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: 'text', text }; + } + continue; + } + + if (event.type === 'tool/call') { + update = { type: 'tool', name: event.data?.name ?? '工具' }; + } else if (event.type === 'tool/result') { + update = { type: 'status', text: '正在整理结果…' }; + } + } + + return update; + } +} + +export class HarnessRpcError extends Error { + constructor(method, error) { + super(`${method}: ${error?.message ?? 'unknown Harness RPC error'}`); + this.name = 'HarnessRpcError'; + this.method = method; + this.code = error?.code ?? 'internal'; + this.details = error?.details ?? {}; + } +} + +export class HarnessClient { + #baseUrl; + #workspace; + #agentPreset; + #autostart; + #dshBin; + #managedProcess = null; + + constructor({ baseUrl, workspace, agentPreset, autostart, dshBin }) { + this.#baseUrl = new URL(baseUrl); + this.#workspace = workspace; + this.#agentPreset = agentPreset; + this.#autostart = autostart; + this.#dshBin = dshBin; + } + + async rpc(method, payload = {}, timeoutMs = 30000, options = {}) { + const rpcId = options.rpcId ?? `feishu-${randomUUID()}`; + const response = await fetch(new URL(`/api/${method}`, this.#baseUrl), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId, method, payload }), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`); + const body = await response.json(); + if (body?.type !== 'server-response' || body?.rpcId !== rpcId) { + throw new Error(`Harness returned an invalid response for ${method}`); + } + if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error); + return body.result.value; + } + + async health() { + await this.rpc('host.describe', {}, 5000); + return true; + } + + async ensureRunning() { + try { + return await this.health(); + } catch (firstError) { + if (!this.#autostart) throw firstError; + } + + if (!this.#managedProcess || this.#managedProcess.exitCode !== null) { + const port = this.#baseUrl.port || (this.#baseUrl.protocol === 'https:' ? '443' : '80'); + this.#managedProcess = spawn(this.#dshBin, [ + 'web', + '--host', + this.#baseUrl.hostname, + '--port', + port, + ], { + cwd: this.#workspace, + env: process.env, + stdio: ['ignore', 'inherit', 'inherit'], + }); + this.#managedProcess.on('error', (error) => { + console.error('[bridge] failed to start Harness:', error.message); + }); + } + + const deadline = Date.now() + 60000; + let lastError; + while (Date.now() < deadline) { + await sleep(1000); + try { + return await this.health(); + } catch (error) { + lastError = error; + } + } + throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`); + } + + async workspaceId() { + const { items } = await this.rpc('workspace.list', {}); + const existing = items.find((item) => item.path === this.#workspace); + if (existing) return existing.workspaceId; + const created = await this.rpc('workspace.create', { path: this.#workspace }); + return created.workspace.workspaceId; + } + + async createSession() { + await this.ensureRunning(); + const workspaceId = await this.workspaceId(); + const created = await this.rpc('session.create', { + workspaceId, + agentPreset: this.#agentPreset, + }); + return created.sessionId; + } + + async sessionExists(sessionId) { + try { + await this.rpc('session.history', { sessionId, maxMessages: 1 }); + return true; + } catch (error) { + if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false; + throw error; + } + } + + async ask(sessionId, text, options = {}) { + if (typeof options === 'number') options = { timeoutMs: options }; + const timeoutMs = options.timeoutMs ?? 600000; + const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null; + await this.ensureRunning(); + const before = await this.rpc('session.history', { sessionId, maxMessages: 1 }); + const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1)); + const promptRpcId = `feishu-${randomUUID()}`; + const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq }); + + await this.rpc('session.prompt', { + sessionId, + mode: 'queue', + content: [{ type: 'text', text }], + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, 30000, { rpcId: promptRpcId }); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(300); + const history = await this.rpc('session.history', { sessionId, maxMessages: 50 }); + const update = tracker.consume(history.events ?? []); + if (update && onUpdate) { + try { + await onUpdate(update); + } catch (error) { + console.warn('[bridge] ignored a progress update failure:', error.message); + } + } + if (!tracker.finished) continue; + if (tracker.answer) return tracker.answer; + + throw new Error(`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`); + } + throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1000)} seconds`); + } + + stopManagedProcess() { + if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM'); + } +} diff --git a/src/channels/feishu/message-utils.mjs b/src/channels/feishu/message-utils.mjs new file mode 100644 index 0000000..91ae484 --- /dev/null +++ b/src/channels/feishu/message-utils.mjs @@ -0,0 +1,50 @@ +export function conversationKey(event) { + const chatType = event?.message?.chat_type; + if (chatType === 'p2p') { + const senderId = event?.sender?.sender_id?.open_id || event?.sender?.sender_id?.user_id; + if (!senderId) throw new Error('Feishu p2p event has no sender id'); + return `p2p:${senderId}`; + } + const chatId = event?.message?.chat_id; + if (!chatId) throw new Error('Feishu group event has no chat id'); + return `group:${chatId}`; +} + +export function extractText(event) { + if (event?.message?.message_type !== 'text') return null; + let parsed; + try { + parsed = JSON.parse(event.message.content); + } catch { + return null; + } + let text = typeof parsed.text === 'string' ? parsed.text : ''; + for (const mention of event.message.mentions ?? []) { + if (typeof mention.key === 'string' && mention.key) text = text.replaceAll(mention.key, ''); + } + return text.trim(); +} + +export function splitText(text, maxChars = 9000) { + if (text.length <= maxChars) return [text]; + const chunks = []; + let remaining = text; + while (remaining.length > maxChars) { + let splitAt = remaining.lastIndexOf('\n', maxChars); + if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n+/, ''); + } + if (remaining) chunks.push(remaining); + return chunks; +} + +export function isBotSender(event) { + return event?.sender?.sender_type === 'bot'; +} + +export function isAllowedSender(event, allowedOpenIds) { + if (!allowedOpenIds || allowedOpenIds.size === 0) return false; + const senderOpenId = event?.sender?.sender_id?.open_id; + return typeof senderOpenId === 'string' && allowedOpenIds.has(senderOpenId); +} diff --git a/src/channels/feishu/multi-bot-controller.mjs b/src/channels/feishu/multi-bot-controller.mjs new file mode 100644 index 0000000..669f02b --- /dev/null +++ b/src/channels/feishu/multi-bot-controller.mjs @@ -0,0 +1,616 @@ +import { randomUUID } from 'node:crypto'; +import { RegistrationManager } from './registration-manager.mjs'; +import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs'; + +const ACTIVE_REGISTRATION_STATES = new Set([ + 'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched', +]); +const MUTABLE_REGISTRATION_STATES = new Set([...ACTIVE_REGISTRATION_STATES, 'saving']); + +function idleConnection() { + return { + ready: false, + feishuLongConnectionState: 'idle', + harnessReachable: false, + }; +} + +function connectionStatus(runtime) { + return runtime ? runtime.status : idleConnection(); +} + +function isConnected(connection) { + return connection.ready === true + && connection.feishuLongConnectionState === 'connected' + && connection.harnessReachable === true; +} + +function maskedAppId(appId) { + return appId.length > 12 + ? `${appId.slice(0, 8)}••••${appId.slice(-4)}` + : 'cli_••••'; +} + +function publicBot(config) { + return { + name: config.botName, + appIdMasked: maskedAppId(config.appId), + activated: config.activated, + domain: config.domain, + }; +} + +function botPhase({ connected, error, connection }) { + if (connected) return 'connected'; + if (error || connection.feishuLongConnectionState === 'failed') return 'error'; + return 'disconnected'; +} + +function makeBotId() { + return `bot_${randomUUID().replaceAll('-', '')}`; +} + +function makeRegistrationId() { + return `reg_${randomUUID().replaceAll('-', '')}`; +} + +function secretRefFor(botId) { + return `DSH_FEISHU_APP_SECRET_${botId.slice(4).toUpperCase()}`; +} + +/** + * Multi-account Feishu orchestration. Each bot owns its credential reference, + * runtime and session store. Config commits are serialized, while unrelated + * runtime lifecycles may proceed independently. + */ +export class MultiBotDshFeishuController { + #registerApp; + #verifyApp; + #credentials; + #configStore; + #createRuntime; + #deleteState; + #createBotId; + #createRegistrationId; + #runtimes = new Map(); + #botErrors = new Map(); + #registrations = new Map(); + #botOwnership = new Map(); + #latestRegistrationId = null; + #configTransition = Promise.resolve(); + #botTransitions = new Map(); + #revision = 1; + #closed = false; + + constructor({ + registerApp, + verifyApp, + credentials, + configStore, + createRuntime, + deleteState = async () => {}, + createBotId = makeBotId, + createRegistrationId = makeRegistrationId, + }) { + if (typeof registerApp !== 'function') throw new Error('registerApp is required'); + if (typeof verifyApp !== 'function') throw new Error('verifyApp is required'); + if (!credentials) throw new Error('credentials service is required'); + if (!configStore || typeof configStore.list !== 'function') { + throw new Error('multi-bot config store is required'); + } + if (typeof createRuntime !== 'function') throw new Error('createRuntime is required'); + if (typeof deleteState !== 'function') throw new Error('deleteState must be a function'); + this.#registerApp = registerApp; + this.#verifyApp = verifyApp; + this.#credentials = credentials; + this.#configStore = configStore; + this.#createRuntime = createRuntime; + this.#deleteState = deleteState; + this.#createBotId = createBotId; + this.#createRegistrationId = createRegistrationId; + } + + async initialize() { + if (this.#closed) return this.status(); + const bots = this.#configStore.list(); + let attempted = false; + await Promise.allSettled(bots.map((config) => this.#withBotTransition(config.id, async () => { + const current = connectionStatus(this.#runtimes.get(config.id)); + if (isConnected(current) + || current.feishuLongConnectionState === 'connecting' + || current.feishuLongConnectionState === 'reconnecting') { + return; + } + attempted = true; + if (config.deletionPending) { + this.#botErrors.set(config.id, { + code: 'deletion_pending', + message: '机器人正在等待完成本地删除,请重试移除。', + }); + return; + } + let resolved; + try { + resolved = await this.#credentials.resolve(config.secretRef); + } catch { + this.#botErrors.set(config.id, { + code: 'missing_credentials', + message: '无法读取机器人凭据,请检查凭据存储。', + }); + return; + } + if (!resolved?.value) { + this.#botErrors.set(config.id, { + code: 'missing_credentials', + message: '机器人凭据缺失,请删除后重新扫码接入。', + }); + return; + } + try { + await this.#startRuntime(config, resolved.value); + this.#botErrors.delete(config.id); + } catch { + this.#botErrors.set(config.id, { + code: 'connection_failed', + message: '机器人暂时无法连接飞书,请重试。', + }); + } + }))); + if (attempted) this.#touch(); + return this.status(); + } + + startRegistration() { + this.#assertOpen(); + const id = this.#createRegistrationId(); + if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || this.#registrations.has(id)) { + throw new Error('Registration id generator returned an invalid or duplicate id'); + } + const record = { id, manager: null, botId: null, createdNew: false, cancelled: false }; + record.manager = new RegistrationManager({ + registerApp: this.#registerApp, + onCredentials: (result) => this.#serializeConfig(() => this.#acceptCredentials(record, result)), + }); + this.#registrations.set(id, record); + this.#latestRegistrationId = id; + this.#trimRegistrations(); + record.manager.start({ + source: 'deepseek-harness', + createOnly: true, + appPreset: { + name: '{user} 的北汇星河 AI 助手', + desc: '连接飞书与 DeepSeek Harness,在聊天中使用企业 AI 助手。', + }, + addons: { + preset: false, + scopes: { tenant: [...REQUIRED_TENANT_SCOPES] }, + events: { items: { tenant: ['im.message.receive_v1'] } }, + }, + }); + this.#touch(); + return this.registrationStatus(id); + } + + hasRegistration(attemptId) { + return this.#registrations.has(attemptId); + } + + registrationStatus(attemptId) { + const record = this.#registrations.get(attemptId); + if (!record) return null; + return this.#status({ registration: record, selectedBotId: record.botId }); + } + + async cancelRegistration(attemptId = this.#latestRegistrationId) { + const record = this.#registrations.get(attemptId); + if (!record) return this.status(); + if (!MUTABLE_REGISTRATION_STATES.has(record.manager.status().state)) { + return this.registrationStatus(attemptId); + } + record.cancelled = true; + record.manager.cancel(); + await this.#serializeConfig(async () => { + if (record.createdNew && record.botId + && this.#botOwnership.get(record.botId) === record.id + && this.#configStore.getBot(record.botId)) { + await this.#withBotTransition(record.botId, () => this.#deleteBot(record.botId)); + } + }); + this.#touch(); + return this.registrationStatus(attemptId) ?? this.status(); + } + + status(botId) { + return this.#status({ + registration: this.#registrations.get(this.#latestRegistrationId) ?? null, + selectedBotId: botId, + }); + } + + async reconnectBot(botId) { + this.#assertOpen(); + return this.#withBotTransition(botId, async () => { + const config = this.#requireBot(botId); + if (config.deletionPending) { + this.#botErrors.set(botId, { + code: 'deletion_pending', + message: '机器人正在等待完成本地删除,请重试移除。', + }); + return this.status(botId); + } + if (isConnected(connectionStatus(this.#runtimes.get(botId)))) { + return this.status(botId); + } + let resolved; + try { + resolved = await this.#credentials.resolve(config.secretRef); + } catch { + resolved = null; + } + if (!resolved?.value) { + this.#botErrors.set(botId, { + code: 'missing_credentials', + message: '机器人凭据缺失,请删除后重新扫码接入。', + }); + this.#touch(); + return this.status(botId); + } + try { + await this.#startRuntime(config, resolved.value); + this.#botErrors.delete(botId); + } catch { + this.#botErrors.set(botId, { + code: 'connection_failed', + message: '机器人暂时无法连接飞书,请重试。', + }); + } + this.#touch(); + return this.status(botId); + }); + } + + async disconnectBot(botId) { + this.#assertOpen(); + // An operational pause only: credentials/config remain durable, so the + // bot reconnects on the next Host start unless it is explicitly deleted. + return this.#withBotTransition(botId, async () => { + this.#requireBot(botId); + await this.#stopRuntime(botId); + this.#botErrors.delete(botId); + this.#touch(); + return this.status(botId); + }); + } + + async deleteBot(botId) { + this.#assertOpen(); + return this.#serializeConfig(() => this.#withBotTransition(botId, async () => { + this.#requireBot(botId); + await this.#deleteBot(botId); + this.#touch(); + return this.status(); + })); + } + + // Compatibility methods for the original one-bot browser contract. + async reconnect() { + const bot = this.#configStore.list()[0]; + return bot ? this.reconnectBot(bot.id) : this.status(); + } + + async disconnect() { + const bot = this.#configStore.list()[0]; + return bot ? this.deleteBot(bot.id) : this.status(); + } + + async close() { + if (this.#closed) return; + this.#closed = true; + for (const record of this.#registrations.values()) { + if (MUTABLE_REGISTRATION_STATES.has(record.manager.status().state)) { + record.cancelled = true; + record.manager.cancel(); + } + } + await this.#configTransition; + await Promise.allSettled([...this.#botTransitions.values()]); + await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id))); + } + + #status({ registration, selectedBotId } = {}) { + const bots = this.#configStore.list().map((config) => { + const connection = connectionStatus(this.#runtimes.get(config.id)); + const connected = isConnected(connection); + const error = this.#botErrors.get(config.id) ?? null; + return { + botId: config.id, + phase: botPhase({ connected, error, connection }), + connected, + configured: true, + bot: publicBot(config), + connection, + error, + }; + }); + const registrationSnapshot = registration ? this.#registrationSnapshot(registration) : { + state: 'idle', attempt: 0, updatedAt: Date.now(), + }; + const registering = ACTIVE_REGISTRATION_STATES.has(registrationSnapshot.state); + const connecting = registrationSnapshot.state === 'saving'; + const registrationOwnsProjection = Boolean(registration) && (registering || connecting); + const selected = bots.find((bot) => bot.botId === selectedBotId) + ?? (registrationOwnsProjection ? null : (bots[0] ?? null)); + const aggregateConnected = bots.some((bot) => bot.connected); + let phase = selected?.phase ?? 'unconfigured'; + if (registering) phase = 'registering'; + else if (connecting) phase = 'connecting'; + else if (registrationSnapshot.state === 'error' && !selected) phase = 'error'; + return { + schemaVersion: 2, + revision: this.#revision, + phase, + connected: selected?.connected ?? false, + configured: bots.length > 0, + bot: selected?.bot ?? null, + connection: selected?.connection ?? idleConnection(), + error: selected?.error ?? registrationSnapshot.error ?? null, + registration: registrationSnapshot, + bots, + totals: { + configured: bots.length, + connected: bots.filter((bot) => bot.connected).length, + }, + anyConnected: aggregateConnected, + }; + } + + #registrationSnapshot(record) { + const snapshot = record.manager.status(); + return { + ...snapshot, + attempt: record.id, + ...(record.botId ? { botId: record.botId } : {}), + }; + } + + async #acceptCredentials(record, result) { + if (record.cancelled) throw new Error('Registration was cancelled'); + const appId = result.client_id; + const appSecret = result.client_secret; + const ownerOpenId = result.user_info?.open_id; + const domain = result.user_info?.tenant_brand === 'lark' ? 'lark' : 'feishu'; + if (!ownerOpenId) throw new Error('Feishu registration returned no owner open_id'); + + const bot = await this.#verifyApp({ appId, appSecret, domain }); + if (record.cancelled) throw new Error('Registration was cancelled'); + const existing = this.#configStore.list().find((candidate) => candidate.appId === appId); + const botId = existing?.id ?? this.#createBotId(); + if (typeof botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(botId) + || (!existing && this.#configStore.getBot(botId))) { + throw new Error('Bot id generator returned an invalid or duplicate id'); + } + const secretRef = existing?.secretRef ?? secretRefFor(botId); + const previousOwnership = this.#botOwnership.get(botId); + const previousSecret = await this.#credentials.resolve(secretRef).catch(() => undefined); + await this.#credentials.set(secretRef, appSecret); + let config; + try { + config = await this.#configStore.saveBot({ + ...existing, + id: botId, + appId, + secretRef, + ownerOpenIds: [...new Set([...(existing?.ownerOpenIds ?? []), ownerOpenId])], + domain, + botName: bot.name, + botOpenId: bot.openId, + activated: bot.activated, + deletionPending: false, + connectedAt: new Date().toISOString(), + createdAt: existing?.createdAt ?? new Date().toISOString(), + }); + record.botId = botId; + record.createdNew = !existing; + this.#botOwnership.set(botId, record.id); + } catch (error) { + try { + await this.#restoreCredential(secretRef, previousSecret); + } catch (restoreError) { + throw new Error('Unable to restore the Feishu credential after a config failure.', { + cause: restoreError, + }); + } + throw error; + } + + if (record.cancelled) { + if (record.createdNew) { + await this.#withBotTransition(botId, () => this.#deleteBot(botId)); + } else { + await this.#configStore.saveBot(existing); + await this.#restoreCredential(secretRef, previousSecret); + if (previousOwnership) this.#botOwnership.set(botId, previousOwnership); + else this.#botOwnership.delete(botId); + } + throw new Error('Registration was cancelled'); + } + let cancellationRolledBack = false; + try { + await this.#withBotTransition(botId, () => this.#startRuntime(config, appSecret)); + if (record.cancelled) { + if (record.createdNew) { + await this.#withBotTransition(botId, () => this.#deleteBot(botId)); + } else { + await this.#configStore.saveBot(existing); + await this.#restoreCredential(secretRef, previousSecret); + if (previousOwnership) this.#botOwnership.set(botId, previousOwnership); + else this.#botOwnership.delete(botId); + if (previousSecret?.value && !existing.deletionPending) { + await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value)); + } else { + await this.#withBotTransition(botId, () => this.#stopRuntime(botId)); + } + } + cancellationRolledBack = true; + throw new Error('Registration was cancelled'); + } + this.#botErrors.delete(botId); + this.#touch(); + } catch (error) { + if (record.cancelled) { + if (!cancellationRolledBack && record.createdNew && this.#configStore.getBot(botId)) { + await this.#withBotTransition(botId, () => this.#deleteBot(botId)); + } else if (!cancellationRolledBack && existing) { + await this.#configStore.saveBot(existing); + await this.#restoreCredential(secretRef, previousSecret); + if (previousOwnership) this.#botOwnership.set(botId, previousOwnership); + else this.#botOwnership.delete(botId); + if (!this.#closed && previousSecret?.value && !existing.deletionPending) { + await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value)); + } else { + await this.#withBotTransition(botId, () => this.#stopRuntime(botId)); + } + } + this.#touch(); + throw error; + } + if (existing && previousSecret?.value) { + try { + await this.#configStore.saveBot(existing); + await this.#restoreCredential(secretRef, previousSecret); + if (previousOwnership) this.#botOwnership.set(botId, previousOwnership); + else this.#botOwnership.delete(botId); + if (!existing.deletionPending) { + await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value)); + this.#botErrors.delete(botId); + } else { + await this.#withBotTransition(botId, () => this.#stopRuntime(botId)); + this.#botErrors.set(botId, { + code: 'deletion_pending', + message: '机器人正在等待完成本地删除,请重试移除。', + }); + } + this.#touch(); + throw error; + } catch (restoreError) { + if (restoreError === error) throw error; + this.#botErrors.set(botId, { + code: 'connection_failed', + message: '机器人连接更新失败,且原连接无法恢复,请重试。', + }); + this.#touch(); + throw new Error('Unable to restore the previous Feishu bot connection.', { + cause: restoreError, + }); + } + } + this.#botErrors.set(botId, { + code: 'connection_failed', + message: '机器人已经创建,但长连接未就绪,请点击重试。', + }); + this.#touch(); + throw error; + } + } + + async #startRuntime(config, appSecret) { + await this.#stopRuntime(config.id); + const runtime = await this.#createRuntime({ + botId: config.id, + config, + appSecret, + }); + this.#runtimes.set(config.id, runtime); + try { + await runtime.start(); + } catch (error) { + if (this.#runtimes.get(config.id) === runtime) this.#runtimes.delete(config.id); + await runtime.stop({ preserveError: true }).catch(() => undefined); + throw error; + } + } + + async #stopRuntime(botId) { + const runtime = this.#runtimes.get(botId); + this.#runtimes.delete(botId); + if (runtime) await runtime.stop(); + } + + async #deleteBot(botId) { + let config = this.#configStore.getBot(botId); + if (!config) return; + if (!config.deletionPending) { + config = await this.#configStore.saveBot({ ...config, deletionPending: true }); + } + await this.#stopRuntime(botId); + try { + await this.#credentials.unset(config.secretRef); + } catch (error) { + this.#botErrors.set(botId, { + code: 'credential_removal_failed', + message: '无法删除机器人凭据,请稍后重试。', + }); + throw new Error('Unable to remove the Feishu credential.', { cause: error }); + } + try { + await this.#deleteState({ botId, config }); + } catch (error) { + this.#botErrors.set(botId, { + code: 'state_cleanup_failed', + message: '无法删除机器人的本地会话数据,请稍后重试。', + }); + throw new Error('Unable to remove the Feishu bot session state.', { cause: error }); + } + await this.#configStore.removeBot(botId); + this.#botErrors.delete(botId); + this.#botOwnership.delete(botId); + } + + async #restoreCredential(secretRef, previous) { + if (previous?.value) await this.#credentials.set(secretRef, previous.value); + else await this.#credentials.unset(secretRef); + } + + #requireBot(botId) { + const config = this.#configStore.getBot(botId); + if (!config) throw new Error('Unknown Feishu bot'); + return config; + } + + #assertOpen() { + if (this.#closed) throw new Error('The Feishu controller is closed'); + } + + #serializeConfig(operation) { + const result = this.#configTransition.then(operation, operation); + this.#configTransition = result.then(() => undefined, () => undefined); + return result; + } + + #withBotTransition(botId, operation) { + const previous = this.#botTransitions.get(botId) ?? Promise.resolve(); + const result = previous.then(operation, operation); + const tail = result.then(() => undefined, () => undefined); + this.#botTransitions.set(botId, tail); + void tail.finally(() => { + if (this.#botTransitions.get(botId) === tail) this.#botTransitions.delete(botId); + }); + return result; + } + + #trimRegistrations() { + if (this.#registrations.size <= 32) return; + for (const [id, record] of this.#registrations) { + if (id === this.#latestRegistrationId) continue; + const state = record.manager.status().state; + if (!ACTIVE_REGISTRATION_STATES.has(state) && state !== 'saving') { + this.#registrations.delete(id); + } + if (this.#registrations.size <= 32) break; + } + } + + #touch() { + this.#revision += 1; + } +} diff --git a/src/channels/feishu/plugin-config-store.mjs b/src/channels/feishu/plugin-config-store.mjs new file mode 100644 index 0000000..f82de86 --- /dev/null +++ b/src/channels/feishu/plugin-config-store.mjs @@ -0,0 +1,204 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +export const LEGACY_FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET'; + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function safeId(value) { + const id = cleanString(value); + return id && /^[A-Za-z0-9_-]{1,128}$/.test(id) ? id : null; +} + +function legacyBotId(appId) { + return `bot_${createHash('sha256').update(appId).digest('hex').slice(0, 24)}`; +} + +function normalizeOwners(value) { + const candidates = Array.isArray(value.ownerOpenIds) + ? value.ownerOpenIds + : [value.ownerOpenId]; + return [...new Set(candidates.map(cleanString).filter(Boolean))]; +} + +function normalizeBot(value, { legacy = false } = {}) { + if (!value || typeof value !== 'object') return null; + const appId = cleanString(value.appId); + const ownerOpenIds = normalizeOwners(value); + if (!appId || ownerOpenIds.length === 0) return null; + const id = safeId(value.id) ?? (legacy ? legacyBotId(appId) : null); + const secretRef = cleanString(value.secretRef) ?? (legacy ? LEGACY_FEISHU_SECRET_REF : null); + if (!id || !secretRef) return null; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretRef)) return null; + const domain = value.domain === 'lark' ? 'lark' : 'feishu'; + return Object.freeze({ + id, + appId, + secretRef, + ownerOpenIds: Object.freeze(ownerOpenIds), + domain, + botName: cleanString(value.botName), + botOpenId: cleanString(value.botOpenId), + activated: value.activated ?? null, + deletionPending: value.deletionPending === true, + connectedAt: cleanString(value.connectedAt), + createdAt: cleanString(value.createdAt) ?? cleanString(value.connectedAt), + }); +} + +function normalizeDocument(value) { + if (!value || typeof value !== 'object') return null; + if (value.version === 2 && Array.isArray(value.bots)) { + const bots = value.bots.map((bot) => normalizeBot(bot)); + if (bots.some((bot) => bot === null)) { + throw new Error('dsh-feishu config contains an invalid bot entry'); + } + const ids = new Set(); + const refs = new Set(); + const appIds = new Set(); + for (const bot of bots) { + if (ids.has(bot.id) || refs.has(bot.secretRef) || appIds.has(bot.appId)) { + throw new Error('dsh-feishu config contains duplicate bot identities'); + } + ids.add(bot.id); + refs.add(bot.secretRef); + appIds.add(bot.appId); + } + return { value: Object.freeze({ version: 2, bots: Object.freeze(bots) }), migrated: false }; + } + + // Version 1 was a single non-secret bot object. Preserve its existing + // credential reference so an environment-backed secret remains usable. + const legacyBot = normalizeBot(value, { legacy: true }); + if (!legacyBot) return null; + return { + value: Object.freeze({ version: 2, bots: Object.freeze([legacyBot]) }), + migrated: true, + }; +} + +/** Stores only non-secret onboarding facts for all Feishu bots. */ +export class PluginConfigStore { + #path; + #value = Object.freeze({ version: 2, bots: Object.freeze([]) }); + #writeQueue = Promise.resolve(); + + constructor(path) { + this.#path = path; + } + + async load() { + try { + const parsed = JSON.parse(await readFile(this.#path, 'utf8')); + const normalized = normalizeDocument(parsed); + if (!normalized) throw new Error('dsh-feishu config is incomplete or invalid'); + this.#value = normalized.value; + if (normalized.migrated) await this.#writeDocument(this.#value); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + this.#value = Object.freeze({ version: 2, bots: Object.freeze([]) }); + } + return this; + } + + /** Backward-compatible single-bot view used by the original controller. */ + get() { + const bot = this.#value.bots[0]; + if (!bot) return null; + const result = structuredClone(bot); + result.ownerOpenId = result.ownerOpenIds[0]; + return result; + } + + list() { + return structuredClone(this.#value.bots); + } + + getBot(id) { + const bot = this.#value.bots.find((candidate) => candidate.id === id); + return bot ? structuredClone(bot) : null; + } + + /** Backward-compatible save replaces the original single-bot view. */ + async save(value) { + const fallback = { ...value }; + if (!fallback.id) fallback.id = legacyBotId(cleanString(fallback.appId) ?? 'invalid'); + if (!fallback.secretRef) fallback.secretRef = LEGACY_FEISHU_SECRET_REF; + const normalized = normalizeBot(fallback); + if (!normalized) throw new Error('Refusing to persist incomplete dsh-feishu configuration'); + await this.#replaceBots([normalized]); + return this.get(); + } + + async saveBot(value) { + const normalized = normalizeBot(value); + if (!normalized) throw new Error('Refusing to persist incomplete dsh-feishu bot configuration'); + return this.#mutate((bots) => { + const collision = bots.find((bot) => bot.secretRef === normalized.secretRef && bot.id !== normalized.id); + if (collision) throw new Error('Refusing to share a credential reference between Feishu bots'); + const appCollision = bots.find((bot) => bot.appId === normalized.appId && bot.id !== normalized.id); + if (appCollision) throw new Error('Refusing to persist the same Feishu app twice'); + const index = bots.findIndex((bot) => bot.id === normalized.id); + if (index === -1) bots.push(normalized); + else bots[index] = normalized; + return structuredClone(normalized); + }); + } + + async removeBot(id) { + if (!safeId(id)) throw new TypeError('Invalid Feishu bot id'); + return this.#mutate((bots) => { + const index = bots.findIndex((bot) => bot.id === id); + 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 = Object.freeze({ version: 2, bots: Object.freeze([]) }); + }); + this.#writeQueue = operation.then(() => undefined, () => undefined); + await operation; + } + + async #replaceBots(bots) { + const document = Object.freeze({ version: 2, bots: Object.freeze([...bots]) }); + const operation = this.#writeQueue.then(async () => { + await this.#writeDocument(document); + this.#value = 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: 2, bots: Object.freeze(bots) }); + await this.#writeDocument(document); + this.#value = document; + }); + this.#writeQueue = operation.then(() => undefined, () => undefined); + await operation; + return result; + } + + async #writeDocument(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/feishu/plugin-controller.mjs b/src/channels/feishu/plugin-controller.mjs new file mode 100644 index 0000000..88e28c4 --- /dev/null +++ b/src/channels/feishu/plugin-controller.mjs @@ -0,0 +1,248 @@ +import { RegistrationManager } from './registration-manager.mjs'; + +export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET'; + +export const REQUIRED_TENANT_SCOPES = Object.freeze([ + 'im:message.p2p_msg:readonly', + 'im:message.group_at_msg:readonly', + 'im:message:send_as_bot', + 'im:message.reactions:write_only', + 'im:message:recall', + 'cardkit:card:write', +]); + +function safeConnectionStatus(runtime) { + if (!runtime) return { + ready: false, + feishuLongConnectionState: 'idle', + harnessReachable: false, + }; + return runtime.status; +} + +function publicBot(config) { + if (!config) return null; + const appIdMasked = config.appId.length > 12 + ? `${config.appId.slice(0, 8)}••••${config.appId.slice(-4)}` + : 'cli_••••'; + return { + name: config.botName, + appIdMasked, + openId: config.botOpenId, + activated: config.activated, + domain: config.domain, + }; +} + +/** Coordinates QR provisioning, durable credentials and the live chat runtime. */ +export class DshFeishuController { + #registerApp; + #verifyApp; + #credentials; + #configStore; + #createRuntime; + #registration; + #runtime = null; + #lastError = null; + #transition = Promise.resolve(); + + constructor({ registerApp, verifyApp, credentials, configStore, createRuntime }) { + if (typeof registerApp !== 'function') throw new Error('registerApp is required'); + if (typeof verifyApp !== 'function') throw new Error('verifyApp is required'); + if (!credentials) throw new Error('credentials service is required'); + if (!configStore) throw new Error('config store is required'); + if (typeof createRuntime !== 'function') throw new Error('createRuntime is required'); + + this.#registerApp = registerApp; + this.#verifyApp = verifyApp; + this.#credentials = credentials; + this.#configStore = configStore; + this.#createRuntime = createRuntime; + this.#registration = new RegistrationManager({ + registerApp: this.#registerApp, + onCredentials: (result) => this.#serialize(() => this.#acceptCredentials(result)), + }); + } + + async initialize() { + const config = this.#configStore.get(); + if (!config) return this.status(); + return this.#serialize(async () => { + const resolved = await this.#credentials.resolve(FEISHU_SECRET_REF); + if (!resolved?.value) { + this.#lastError = { + code: 'missing_credentials', + message: '机器人凭据缺失,请重新扫码接入。', + }; + return this.status(); + } + try { + await this.#startRuntime(config, resolved.value); + this.#lastError = null; + } catch { + this.#lastError = { + code: 'connection_failed', + message: '机器人暂时无法连接飞书,请重试。', + }; + } + return this.status(); + }); + } + + startRegistration() { + this.#lastError = null; + this.#registration.start({ + source: 'deepseek-harness', + createOnly: true, + appPreset: { + name: '{user} 的北汇星河 AI 助手', + desc: '连接飞书与 DeepSeek Harness,在聊天中使用企业 AI 助手。', + }, + addons: { + preset: false, + scopes: { tenant: [...REQUIRED_TENANT_SCOPES] }, + events: { items: { tenant: ['im.message.receive_v1'] } }, + }, + }); + return this.status(); + } + + cancelRegistration() { + this.#registration.cancel(); + return this.status(); + } + + async reconnect() { + return this.#serialize(async () => { + const config = this.#configStore.get(); + const resolved = await this.#credentials.resolve(FEISHU_SECRET_REF); + if (!config || !resolved?.value) { + this.#lastError = { + code: 'missing_credentials', + message: '没有可用的机器人凭据,请重新扫码接入。', + }; + return this.status(); + } + try { + await this.#startRuntime(config, resolved.value); + this.#lastError = null; + } catch { + this.#lastError = { + code: 'connection_failed', + message: '机器人暂时无法连接飞书,请重试。', + }; + } + return this.status(); + }); + } + + async disconnect() { + this.#registration.cancel(); + return this.#serialize(async () => { + await this.#stopRuntime(); + await this.#configStore.clear(); + try { + await this.#credentials.unset(FEISHU_SECRET_REF); + } catch { + // A read-only environment value may shadow the managed store. Clearing + // the non-secret config still prevents automatic reuse of that value. + } + this.#lastError = null; + return this.status(); + }); + } + + async close() { + this.#registration.cancel(); + await this.#serialize(() => this.#stopRuntime()); + } + + status() { + const config = this.#configStore.get(); + const registration = this.#registration.status(); + const connection = safeConnectionStatus(this.#runtime); + const connected = connection.ready === true + && connection.feishuLongConnectionState === 'connected' + && connection.harnessReachable === true; + + let phase = 'unconfigured'; + if (connected) phase = 'connected'; + else if (['starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched'].includes(registration.state)) { + phase = 'registering'; + } else if (registration.state === 'saving') phase = 'connecting'; + else if (this.#lastError || registration.state === 'error') phase = 'error'; + else if (config) phase = 'disconnected'; + + return { + phase, + connected, + configured: Boolean(config), + bot: publicBot(config), + registration, + connection, + error: this.#lastError ?? registration.error ?? null, + }; + } + + async #acceptCredentials(result) { + const appId = result.client_id; + const appSecret = result.client_secret; + const ownerOpenId = result.user_info?.open_id; + const domain = result.user_info?.tenant_brand === 'lark' ? 'lark' : 'feishu'; + if (!ownerOpenId) throw new Error('Feishu registration returned no owner open_id'); + + const bot = await this.#verifyApp({ appId, appSecret, domain }); + await this.#credentials.set(FEISHU_SECRET_REF, appSecret); + let config; + try { + config = await this.#configStore.save({ + appId, + ownerOpenId, + domain, + botName: bot.name, + botOpenId: bot.openId, + activated: bot.activated, + connectedAt: new Date().toISOString(), + }); + } catch (error) { + await this.#credentials.unset(FEISHU_SECRET_REF).catch(() => undefined); + throw error; + } + + try { + await this.#startRuntime(config, appSecret); + this.#lastError = null; + } catch (error) { + this.#lastError = { + code: 'connection_failed', + message: '机器人已经创建,但长连接未就绪,请点击重试。', + }; + throw error; + } + } + + async #startRuntime(config, appSecret) { + await this.#stopRuntime(); + const runtime = await this.#createRuntime({ config, appSecret }); + this.#runtime = runtime; + try { + await runtime.start(); + } catch (error) { + if (this.#runtime === runtime) this.#runtime = null; + await runtime.stop({ preserveError: true }).catch(() => undefined); + throw error; + } + } + + async #stopRuntime() { + const runtime = this.#runtime; + this.#runtime = null; + if (runtime) await runtime.stop(); + } + + #serialize(operation) { + const result = this.#transition.then(operation, operation); + this.#transition = result.then(() => undefined, () => undefined); + return result; + } +} diff --git a/src/channels/feishu/registration-manager.mjs b/src/channels/feishu/registration-manager.mjs new file mode 100644 index 0000000..4993a87 --- /dev/null +++ b/src/channels/feishu/registration-manager.mjs @@ -0,0 +1,345 @@ +const ACTIVE_STATES = new Set([ + 'starting', + 'qr_ready', + 'polling', + 'slow_down', + 'domain_switched', + 'saving', +]); + +const SDK_POLLING_STATES = new Set([ + 'polling', + 'slow_down', + 'domain_switched', +]); + +export const REGISTRATION_STATES = Object.freeze({ + IDLE: 'idle', + STARTING: 'starting', + QR_READY: 'qr_ready', + POLLING: 'polling', + SLOW_DOWN: 'slow_down', + DOMAIN_SWITCHED: 'domain_switched', + SAVING: 'saving', + SUCCEEDED: 'succeeded', + EXPIRED: 'expired', + CANCELLED: 'cancelled', + ERROR: 'error', +}); + +function errorCode(error) { + if (['access_denied', 'expired_token', 'abort'].includes(error?.code)) return error.code; + return 'registration_failed'; +} + +function publicError(error) { + const code = errorCode(error); + const messages = { + access_denied: 'Registration was denied.', + abort: 'Registration was cancelled.', + expired_token: 'The registration QR code expired.', + }; + + // SDK/network errors are deliberately not copied verbatim. Besides keeping + // the API stable, this prevents a downstream error from reflecting a secret + // into a status response. + return { + code, + message: messages[code] ?? 'Unable to register the Feishu app.', + }; +} + +function expirySeconds(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new TypeError('registerApp onQRCodeReady returned an invalid expireIn'); + } + return seconds; +} + +function copyUserInfo(userInfo) { + if (userInfo === undefined) return undefined; + if (userInfo === null || typeof userInfo !== 'object' || Array.isArray(userInfo)) { + throw new TypeError('registerApp returned invalid user_info'); + } + return { ...userInfo }; +} + +/** + * Owns one Feishu device-registration attempt at a time. + * + * `start()` intentionally does not await the long-running SDK poll. Consumers + * start an attempt and then poll `status()` until it reaches a terminal state. + * The App Secret never becomes manager state and is only handed to the injected + * `onCredentials` callback. + */ +export class RegistrationManager { + #registerApp; + #onCredentials; + #now; + #setTimeout; + #clearTimeout; + #attempt = 0; + #active = null; + #snapshot; + + constructor({ + registerApp, + onCredentials, + now = Date.now, + setTimeout: setTimeoutFn = globalThis.setTimeout, + clearTimeout: clearTimeoutFn = globalThis.clearTimeout, + } = {}) { + if (typeof registerApp !== 'function') { + throw new TypeError('RegistrationManager requires a registerApp function'); + } + if (typeof onCredentials !== 'function') { + throw new TypeError('RegistrationManager requires an onCredentials function'); + } + if (typeof now !== 'function' || typeof setTimeoutFn !== 'function' || typeof clearTimeoutFn !== 'function') { + throw new TypeError('RegistrationManager clock dependencies must be functions'); + } + + this.#registerApp = registerApp; + this.#onCredentials = onCredentials; + this.#now = now; + this.#setTimeout = setTimeoutFn; + this.#clearTimeout = clearTimeoutFn; + this.#snapshot = this.#makeSnapshot(null, REGISTRATION_STATES.IDLE); + } + + start(registerOptions = {}) { + if (registerOptions === null || typeof registerOptions !== 'object' || Array.isArray(registerOptions)) { + throw new TypeError('Registration options must be an object'); + } + + this.#supersedeActiveAttempt(); + + const run = { + id: ++this.#attempt, + controller: new AbortController(), + qrCodeUrl: null, + expiresAt: null, + pollIntervalSeconds: null, + expiryTimer: null, + }; + this.#active = run; + this.#snapshot = this.#makeSnapshot(run, REGISTRATION_STATES.STARTING); + + const options = { + ...registerOptions, + signal: run.controller.signal, + onQRCodeReady: (info) => this.#onQRCodeReady(run, info), + onStatusChange: (info) => this.#onStatusChange(run, info), + }; + + // Put the SDK invocation on a microtask so a synchronous throw and a + // Promise rejection follow the same path without making start() blocking. + const registration = Promise.resolve().then(() => this.#registerApp(options)); + void registration.then( + (result) => this.#onRegistrationSucceeded(run, result), + (error) => this.#onRegistrationFailed(run, error), + ); + + return this.status(); + } + + status() { + this.#expireIfNeeded(); + + const snapshot = { ...this.#snapshot }; + if (snapshot.error) snapshot.error = { ...snapshot.error }; + + const run = this.#active; + if (run && run.expiresAt !== null && ACTIVE_STATES.has(snapshot.state)) { + snapshot.remainingSeconds = Math.max(0, Math.ceil((run.expiresAt - this.#now()) / 1000)); + } + return snapshot; + } + + cancel() { + const run = this.#active; + if (!run) return this.status(); + + this.#finishRun(run, REGISTRATION_STATES.CANCELLED, { + error: { + code: 'abort', + message: 'Registration was cancelled.', + }, + }); + run.controller.abort(); + return this.status(); + } + + #isCurrent(run) { + return this.#active === run; + } + + #makeSnapshot(run, state, extra = {}) { + const snapshot = { + state, + attempt: run?.id ?? this.#attempt, + updatedAt: this.#now(), + ...extra, + }; + + if (run?.qrCodeUrl && ACTIVE_STATES.has(state)) { + snapshot.qrCodeUrl = run.qrCodeUrl; + snapshot.expiresAt = run.expiresAt; + } + if (run?.pollIntervalSeconds !== null && ACTIVE_STATES.has(state)) { + snapshot.pollIntervalSeconds = run.pollIntervalSeconds; + } + return snapshot; + } + + #setRunState(run, state, extra = {}) { + if (!this.#isCurrent(run)) return; + this.#snapshot = this.#makeSnapshot(run, state, extra); + } + + #onQRCodeReady(run, info) { + if (!this.#isCurrent(run)) return; + if (typeof info?.url !== 'string' || !info.url) { + throw new TypeError('registerApp onQRCodeReady returned an invalid URL'); + } + + const seconds = expirySeconds(info.expireIn); + run.qrCodeUrl = info.url; + run.expiresAt = this.#now() + (seconds * 1000); + this.#clearExpiryTimer(run); + run.expiryTimer = this.#setTimeout(() => this.#expireRun(run), seconds * 1000); + run.expiryTimer?.unref?.(); + this.#setRunState(run, REGISTRATION_STATES.QR_READY); + } + + #onStatusChange(run, info) { + if (!this.#isCurrent(run) || !SDK_POLLING_STATES.has(info?.status)) return; + if (info.status === REGISTRATION_STATES.SLOW_DOWN && Number.isFinite(Number(info.interval))) { + run.pollIntervalSeconds = Number(info.interval); + } + this.#setRunState(run, info.status); + } + + async #onRegistrationSucceeded(run, result) { + if (!this.#isCurrent(run)) return; + + const clientId = result?.client_id; + const clientSecret = result?.client_secret; + if (typeof clientId !== 'string' || !clientId || typeof clientSecret !== 'string' || !clientSecret) { + this.#finishRun(run, REGISTRATION_STATES.ERROR, { + error: { + code: 'invalid_credentials', + message: 'Feishu registration returned invalid credentials.', + }, + }); + return; + } + + let userInfo; + try { + userInfo = copyUserInfo(result.user_info); + } catch { + this.#finishRun(run, REGISTRATION_STATES.ERROR, { + error: { + code: 'invalid_credentials', + message: 'Feishu registration returned invalid credentials.', + }, + }); + return; + } + + // Once the SDK has returned credentials, QR expiry no longer applies. + // Remove the device URL before awaiting persistence so it also disappears + // from the public `saving` status. + this.#clearExpiryTimer(run); + run.qrCodeUrl = null; + run.expiresAt = null; + run.pollIntervalSeconds = null; + this.#setRunState(run, REGISTRATION_STATES.SAVING); + try { + await this.#onCredentials({ + client_id: clientId, + client_secret: clientSecret, + user_info: userInfo, + }); + } catch { + if (this.#isCurrent(run)) { + this.#finishRun(run, REGISTRATION_STATES.ERROR, { + error: { + code: 'credentials_callback_failed', + message: 'Unable to store the Feishu credentials.', + }, + }); + } + return; + } + + if (this.#isCurrent(run)) { + this.#finishRun(run, REGISTRATION_STATES.SUCCEEDED); + } + } + + #onRegistrationFailed(run, error) { + if (!this.#isCurrent(run)) return; + + const code = errorCode(error); + if (code === 'expired_token') { + this.#finishRun(run, REGISTRATION_STATES.EXPIRED, { + error: publicError(error), + }); + return; + } + if (code === 'abort') { + this.#finishRun(run, REGISTRATION_STATES.CANCELLED, { + error: publicError(error), + }); + return; + } + this.#finishRun(run, REGISTRATION_STATES.ERROR, { + error: publicError(error), + }); + } + + #expireIfNeeded() { + const run = this.#active; + if (run && run.expiresAt !== null && this.#now() >= run.expiresAt) { + this.#expireRun(run); + } + } + + #expireRun(run) { + if (!this.#isCurrent(run)) return; + this.#finishRun(run, REGISTRATION_STATES.EXPIRED, { + error: { + code: 'expired_token', + message: 'The registration QR code expired.', + }, + }); + run.controller.abort(); + } + + #finishRun(run, state, extra = {}) { + if (!this.#isCurrent(run)) return; + this.#clearExpiryTimer(run); + this.#snapshot = this.#makeSnapshot(run, state, extra); + this.#active = null; + } + + #clearExpiryTimer(run) { + if (run.expiryTimer !== null) { + this.#clearTimeout(run.expiryTimer); + run.expiryTimer = null; + } + } + + #supersedeActiveAttempt() { + const previous = this.#active; + if (!previous) return; + this.#clearExpiryTimer(previous); + this.#active = null; + previous.controller.abort(); + } +} + +export default RegistrationManager; diff --git a/src/channels/feishu/state-store.mjs b/src/channels/feishu/state-store.mjs new file mode 100644 index 0000000..a5cfec2 --- /dev/null +++ b/src/channels/feishu/state-store.mjs @@ -0,0 +1,71 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); + +export class StateStore { + #path; + #state = structuredClone(EMPTY_STATE); + #writeQueue = Promise.resolve(); + + constructor(path) { + this.#path = path; + } + + async load() { + try { + const parsed = JSON.parse(await readFile(this.#path, 'utf8')); + this.#state = { + version: 1, + sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {}, + seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [], + }; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + 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 > 1000) { + this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1000); + } + await this.#persist(); + } + + snapshot() { + return structuredClone(this.#state); + } + + async #persist() { + const snapshot = JSON.stringify(this.#state, null, 2) + '\n'; + this.#writeQueue = 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); + }); + await this.#writeQueue; + } +} diff --git a/src/channels/weixin/config-store.mjs b/src/channels/weixin/config-store.mjs new file mode 100644 index 0000000..517b3b8 --- /dev/null +++ b/src/channels/weixin/config-store.mjs @@ -0,0 +1,182 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { normalizeWeixinApiBaseUrl } from './weixin-api.mjs'; + +const EMPTY_DOCUMENT = Object.freeze({ version: 1, accounts: Object.freeze([]) }); + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function safeBotId(value) { + const id = cleanString(value); + return id && /^wx_[a-f0-9]{24}$/.test(id) ? id : null; +} + +function safeTokenRef(value) { + const ref = cleanString(value); + return ref && /^DSH_WEIXIN_BOT_TOKEN_[A-F0-9]{24}$/.test(ref) ? ref : null; +} + +export function deriveWeixinBotIdentity(accountId) { + const raw = cleanString(accountId); + if (!raw) throw new TypeError('accountId is required'); + const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24); + return { + botId: `wx_${digest}`, + tokenRef: `DSH_WEIXIN_BOT_TOKEN_${digest.toUpperCase()}`, + }; +} + +export function maskWeixinAccountId(accountId) { + const value = cleanString(accountId) ?? ''; + if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : '微信机器人'; + return `${value.slice(0, 6)}••••${value.slice(-4)}`; +} + +function normalizeAccount(value) { + if (!value || typeof value !== 'object') return null; + const accountId = cleanString(value.accountId); + const ownerUserId = cleanString(value.ownerUserId); + const botId = safeBotId(value.botId); + const tokenRef = safeTokenRef(value.tokenRef); + if (!accountId || !ownerUserId || !botId || !tokenRef) return null; + const derived = deriveWeixinBotIdentity(accountId); + if (derived.botId !== botId || derived.tokenRef !== tokenRef) return null; + let baseUrl; + try { + baseUrl = normalizeWeixinApiBaseUrl(value.baseUrl); + } catch { + return null; + } + return Object.freeze({ + botId, + accountId, + tokenRef, + ownerUserId, + baseUrl, + createdAt: cleanString(value.createdAt) ?? new Date().toISOString(), + connectedAt: cleanString(value.connectedAt), + }); +} + +function normalizeDocument(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; + const ids = new Set(); + const accountIds = new Set(); + const refs = new Set(); + for (const account of accounts) { + if (ids.has(account.botId) || accountIds.has(account.accountId) || refs.has(account.tokenRef)) { + return null; + } + ids.add(account.botId); + accountIds.add(account.accountId); + refs.add(account.tokenRef); + } + return Object.freeze({ version: 1, accounts: Object.freeze(accounts) }); +} + +export class WeixinConfigStore { + #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-weixin config contains invalid account data'); + this.#value = normalized; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + this.#value = EMPTY_DOCUMENT; + } + return this; + } + + list() { + return structuredClone(this.#value.accounts); + } + + get(botId) { + const account = this.#value.accounts.find((candidate) => candidate.botId === botId); + return account ? structuredClone(account) : null; + } + + getByAccountId(accountId) { + const account = this.#value.accounts.find((candidate) => candidate.accountId === accountId); + return account ? structuredClone(account) : null; + } + + async save(value) { + const normalized = normalizeAccount(value); + if (!normalized) throw new Error('Refusing to persist incomplete dsh-weixin account data'); + return this.#mutate((accounts) => { + const accountCollision = accounts.find( + (account) => account.accountId === normalized.accountId && account.botId !== normalized.botId, + ); + const refCollision = accounts.find( + (account) => account.tokenRef === normalized.tokenRef && account.botId !== normalized.botId, + ); + if (accountCollision || refCollision) throw new Error('Duplicate Weixin account identity'); + const index = accounts.findIndex((account) => account.botId === normalized.botId); + if (index === -1) accounts.push(normalized); + else accounts[index] = normalized; + return structuredClone(normalized); + }); + } + + async remove(botId) { + if (!safeBotId(botId)) throw new TypeError('Invalid Weixin bot id'); + return this.#mutate((accounts) => { + const index = accounts.findIndex((account) => account.botId === botId); + if (index === -1) return null; + const [removed] = accounts.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 accounts = [...this.#value.accounts]; + result = mutator(accounts); + const document = Object.freeze({ version: 1, accounts: Object.freeze(accounts) }); + 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/weixin/harness-client.mjs b/src/channels/weixin/harness-client.mjs new file mode 100644 index 0000000..eb893de --- /dev/null +++ b/src/channels/weixin/harness-client.mjs @@ -0,0 +1,259 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function assistantMessageText(event) { + return (event?.data?.message?.content ?? []) + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text) + .join('\n') + .trim(); +} + +export class HarnessReplyTracker { + #promptRpcId; + #lastSeq; + #openTurn = null; + #targetTurn = null; + #stepText = new Map(); + #latestText = ''; + #finished = false; + #reason = null; + + constructor({ promptRpcId, afterSeq = -1 }) { + this.#promptRpcId = promptRpcId; + this.#lastSeq = afterSeq; + } + + get finished() { + return this.#finished; + } + + get answer() { + return this.#latestText.trim(); + } + + get reason() { + return this.#reason; + } + + consume(entries) { + let update = null; + const ordered = [...entries] + .map((entry) => entry?.event ?? entry) + .filter(Boolean) + .sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1)); + + for (const event of ordered) { + const seq = event.seq ?? -1; + if (seq <= this.#lastSeq) continue; + this.#lastSeq = seq; + + if (event.type === 'turn/start') this.#openTurn = event.data?.turn ?? null; + + if (event.type === 'user/message' && event.data?.source?.rpcId === this.#promptRpcId) { + this.#targetTurn = this.#openTurn; + continue; + } + if (this.#targetTurn === null) continue; + + if (event.type === 'turn/end') { + if (event.data?.turn !== this.#targetTurn) continue; + this.#finished = true; + this.#reason = event.data?.reason ?? null; + this.#openTurn = null; + continue; + } + if (event.data?.turn !== this.#targetTurn) continue; + + if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') { + const step = event.data?.step ?? 0; + const index = event.data.chunk.index ?? 0; + const key = `${step}:${index}`; + this.#stepText.set(key, (this.#stepText.get(key) ?? '') + event.data.chunk.text); + const prefix = `${step}:`; + const text = [...this.#stepText.entries()] + .filter(([partKey]) => partKey.startsWith(prefix)) + .sort(([left], [right]) => Number(left.split(':')[1]) - Number(right.split(':')[1])) + .map(([, part]) => part) + .join('\n') + .trim(); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: 'text', text }; + } + continue; + } + + if (event.type === 'assistant/message') { + const text = assistantMessageText(event); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: 'text', text }; + } + continue; + } + + if (event.type === 'tool/call') { + update = { type: 'tool', name: event.data?.name ?? '工具' }; + } else if (event.type === 'tool/result') { + update = { type: 'status', text: '正在整理结果…' }; + } + } + return update; + } +} + +export class HarnessRpcError extends Error { + constructor(method, error) { + super(`${method}: ${error?.message ?? 'unknown Harness RPC error'}`); + this.name = 'HarnessRpcError'; + this.method = method; + this.code = error?.code ?? 'internal'; + this.details = error?.details ?? {}; + } +} + +export class HarnessClient { + #baseUrl; + #workspace; + #agentPreset; + #autostart; + #dshBin; + #managedProcess = null; + + constructor({ baseUrl, workspace, agentPreset = 'standard', autostart = false, dshBin = 'dsh' }) { + this.#baseUrl = new URL(baseUrl); + this.#workspace = workspace; + this.#agentPreset = agentPreset; + this.#autostart = autostart; + this.#dshBin = dshBin; + } + + async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) { + const rpcId = options.rpcId ?? `weixin-${randomUUID()}`; + const response = await fetch(new URL(`/api/${method}`, this.#baseUrl), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId, method, payload }), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`); + const body = await response.json(); + if (body?.type !== 'server-response' || body?.rpcId !== rpcId) { + throw new Error(`Harness returned an invalid response for ${method}`); + } + if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error); + return body.result.value; + } + + async health() { + await this.rpc('host.describe', {}, 5_000); + return true; + } + + async ensureRunning() { + try { + return await this.health(); + } catch (firstError) { + if (!this.#autostart) throw firstError; + } + + if (!this.#managedProcess || this.#managedProcess.exitCode !== null) { + const port = this.#baseUrl.port || (this.#baseUrl.protocol === 'https:' ? '443' : '80'); + this.#managedProcess = spawn(this.#dshBin, [ + 'web', '--host', this.#baseUrl.hostname, '--port', port, + ], { + cwd: this.#workspace, + env: process.env, + stdio: ['ignore', 'inherit', 'inherit'], + }); + this.#managedProcess.on('error', (error) => { + console.error('[dsh-weixin] failed to start Harness:', error.message); + }); + } + + const deadline = Date.now() + 60_000; + let lastError; + while (Date.now() < deadline) { + await sleep(1_000); + try { + return await this.health(); + } catch (error) { + lastError = error; + } + } + throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`); + } + + async workspaceId() { + const { items } = await this.rpc('workspace.list', {}); + const existing = items.find((item) => item.path === this.#workspace); + if (existing) return existing.workspaceId; + const created = await this.rpc('workspace.create', { path: this.#workspace }); + return created.workspace.workspaceId; + } + + async createSession() { + await this.ensureRunning(); + const workspaceId = await this.workspaceId(); + const created = await this.rpc('session.create', { + workspaceId, + agentPreset: this.#agentPreset, + }); + return created.sessionId; + } + + async sessionExists(sessionId) { + try { + await this.rpc('session.history', { sessionId, maxMessages: 1 }); + return true; + } catch (error) { + if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false; + throw error; + } + } + + async ask(sessionId, text, options = {}) { + if (typeof options === 'number') options = { timeoutMs: options }; + const timeoutMs = options.timeoutMs ?? 600_000; + const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null; + await this.ensureRunning(); + const before = await this.rpc('session.history', { sessionId, maxMessages: 1 }); + const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1)); + const promptRpcId = `weixin-${randomUUID()}`; + const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq }); + + await this.rpc('session.prompt', { + sessionId, + mode: 'queue', + content: [{ type: 'text', text }], + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, 30_000, { rpcId: promptRpcId }); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(300); + const history = await this.rpc('session.history', { sessionId, maxMessages: 50 }); + const update = tracker.consume(history.events ?? []); + if (update && onUpdate) { + try { + await onUpdate(update); + } catch (error) { + console.warn('[dsh-weixin] ignored a progress update failure:', error.message); + } + } + if (!tracker.finished) continue; + if (tracker.answer) return tracker.answer; + throw new Error( + `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`, + ); + } + throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`); + } + + stopManagedProcess() { + if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM'); + } +} diff --git a/src/channels/weixin/state-store.mjs b/src/channels/weixin/state-store.mjs new file mode 100644 index 0000000..d7dcd40 --- /dev/null +++ b/src/channels/weixin/state-store.mjs @@ -0,0 +1,112 @@ +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const EMPTY_STATE = Object.freeze({ + version: 1, + sessions: {}, + seenMessageIds: [], + getUpdatesBuf: '', +}); + +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) + : [], + getUpdatesBuf: typeof value.getUpdatesBuf === 'string' ? value.getUpdatesBuf : '', + }; +} + +export class WeixinStateStore { + #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(); + } + + getUpdatesBuf() { + return this.#state.getUpdatesBuf; + } + + async setGetUpdatesBuf(value) { + if (typeof value !== 'string' || value === this.#state.getUpdatesBuf) return; + this.#state.getUpdatesBuf = value; + await this.#persist(); + } + + snapshot() { + return structuredClone(this.#state); + } + + 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/weixin/weixin-api.mjs b/src/channels/weixin/weixin-api.mjs new file mode 100644 index 0000000..3d3717f --- /dev/null +++ b/src/channels/weixin/weixin-api.mjs @@ -0,0 +1,319 @@ +import { randomBytes, randomUUID } from 'node:crypto'; + +export const WEIXIN_QR_BASE_URL = 'https://ilinkai.weixin.qq.com/'; +export const WEIXIN_PROTOCOL_VERSION = '2.4.6'; +export const DEFAULT_BOT_TYPE = '3'; + +const ILINK_APP_ID = 'bot'; +const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6; +const DEFAULT_TIMEOUT_MS = 15_000; +const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000; +const LOGIN_STATUSES = new Set([ + 'wait', + 'scaned', + 'confirmed', + 'expired', + 'scaned_but_redirect', + 'need_verifycode', + 'verify_code_blocked', + 'binded_redirect', +]); + +export class WeixinApiError extends Error { + constructor(code, message, options = {}) { + super(message, options); + this.name = 'WeixinApiError'; + this.code = code; + this.status = options.status; + } +} + +function nonEmptyString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function isWeixinHost(hostname) { + const normalized = hostname.toLowerCase().replace(/\.$/, ''); + return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com'); +} + +export function normalizeWeixinApiBaseUrl(value) { + let url; + try { + url = new URL(value); + } catch { + throw new WeixinApiError('invalid-base-url', '微信服务返回了无效的连接地址。'); + } + if (url.protocol !== 'https:' || !isWeixinHost(url.hostname) + || (url.port !== '' && url.port !== '443')) { + throw new WeixinApiError('untrusted-base-url', '微信服务返回了不受信任的连接地址。'); + } + url.username = ''; + url.password = ''; + url.search = ''; + url.hash = ''; + if (!url.pathname.endsWith('/')) url.pathname += '/'; + return url.toString(); +} + +export function normalizeWeixinQrUrl(value) { + const text = nonEmptyString(value); + if (!text) throw new WeixinApiError('invalid-qr', '微信服务没有返回扫码地址。'); + let url; + try { + url = new URL(text); + } catch { + throw new WeixinApiError('invalid-qr', '微信服务返回了无效的扫码地址。'); + } + if (url.protocol !== 'https:' || !isWeixinHost(url.hostname)) { + throw new WeixinApiError('untrusted-qr', '微信服务返回了不受信任的扫码地址。'); + } + return url.toString(); +} + +function commonHeaders() { + return { + 'iLink-App-Id': ILINK_APP_ID, + 'iLink-App-ClientVersion': String(ILINK_CLIENT_VERSION), + }; +} + +function authenticatedHeaders(token) { + const headers = { + ...commonHeaders(), + 'content-type': 'application/json', + AuthorizationType: 'ilink_bot_token', + 'X-WECHAT-UIN': Buffer.from(String(randomBytes(4).readUInt32BE(0)), 'utf8').toString('base64'), + }; + if (nonEmptyString(token)) headers.Authorization = `Bearer ${token.trim()}`; + return headers; +} + +function baseInfo() { + return { + channel_version: WEIXIN_PROTOCOL_VERSION, + bot_agent: 'DeepSeekHarness/0.1.0', + }; +} + +function abortError(signal) { + if (signal?.reason instanceof Error) return signal.reason; + return new DOMException('The operation was aborted', 'AbortError'); +} + +async function requestJson(fetchImpl, { + method, + baseUrl, + endpoint, + body, + token, + timeoutMs = DEFAULT_TIMEOUT_MS, + signal, + authenticated = true, +}) { + const trustedBase = normalizeWeixinApiBaseUrl(baseUrl); + const url = new URL(endpoint, trustedBase); + if (!isWeixinHost(url.hostname)) { + throw new WeixinApiError('untrusted-endpoint', '拒绝访问不受信任的微信服务地址。'); + } + + const controller = new AbortController(); + let timedOut = false; + const onAbort = () => controller.abort(signal?.reason); + if (signal?.aborted) throw abortError(signal); + signal?.addEventListener('abort', onAbort, { once: true }); + const timer = timeoutMs > 0 ? setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs) : null; + + try { + const response = await fetchImpl(url, { + method, + headers: authenticated ? authenticatedHeaders(token) : commonHeaders(), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: controller.signal, + }); + if (!response.ok) { + throw new WeixinApiError( + 'http-error', + `微信服务请求失败(HTTP ${response.status})。`, + { status: response.status }, + ); + } + try { + return await response.json(); + } catch (error) { + throw new WeixinApiError('invalid-response', '微信服务返回了无法解析的响应。', { cause: error }); + } + } catch (error) { + if (signal?.aborted) throw abortError(signal); + if (timedOut) { + throw new WeixinApiError('timeout', '微信服务请求超时。', { cause: error }); + } + if (error instanceof WeixinApiError) throw error; + throw new WeixinApiError('network-error', '暂时无法访问微信服务。', { cause: error }); + } finally { + if (timer) clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } +} + +function validateLoginResponse(value) { + if (!value || typeof value !== 'object' || !LOGIN_STATUSES.has(value.status)) { + throw new WeixinApiError('invalid-login-status', '微信服务返回了无法识别的扫码状态。'); + } + return value; +} + +export function createWeixinApi({ fetchImpl = fetch } = {}) { + if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function'); + + return Object.freeze({ + async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) { + const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10); + const response = await requestJson(fetchImpl, { + method: 'POST', + baseUrl: WEIXIN_QR_BASE_URL, + endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, + body: { local_token_list: tokens }, + timeoutMs: 10_000, + signal, + }); + const qrcode = nonEmptyString(response?.qrcode); + if (!qrcode) throw new WeixinApiError('invalid-qr', '微信服务没有返回二维码令牌。'); + return { + qrcode, + qrcodeUrl: normalizeWeixinQrUrl(response.qrcode_img_content), + }; + }, + + async pollLogin({ qrcode, baseUrl = WEIXIN_QR_BASE_URL, verifyCode, signal }) { + const qr = nonEmptyString(qrcode); + if (!qr) throw new TypeError('qrcode is required'); + let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qr)}`; + if (nonEmptyString(verifyCode)) endpoint += `&verify_code=${encodeURIComponent(verifyCode.trim())}`; + const response = await requestJson(fetchImpl, { + method: 'GET', + baseUrl, + endpoint, + timeoutMs: DEFAULT_LONG_POLL_TIMEOUT_MS, + signal, + authenticated: false, + }); + return validateLoginResponse(response); + }, + + async getUpdates({ baseUrl, token, getUpdatesBuf = '', timeoutMs, signal }) { + try { + return await requestJson(fetchImpl, { + method: 'POST', + baseUrl, + endpoint: 'ilink/bot/getupdates', + body: { get_updates_buf: getUpdatesBuf, base_info: baseInfo() }, + token, + timeoutMs: timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS, + signal, + }); + } catch (error) { + if (error instanceof WeixinApiError && error.code === 'timeout') { + return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf }; + } + throw error; + } + }, + + async sendText({ baseUrl, token, toUserId, text, contextToken, runId, signal }) { + const recipient = nonEmptyString(toUserId); + const content = nonEmptyString(text); + if (!recipient || !content) throw new TypeError('toUserId and text are required'); + const response = await requestJson(fetchImpl, { + method: 'POST', + baseUrl, + endpoint: 'ilink/bot/sendmessage', + token, + signal, + body: { + msg: { + from_user_id: '', + to_user_id: recipient, + client_id: `dsh-weixin-${randomUUID()}`, + message_type: 2, + message_state: 2, + item_list: [{ type: 1, text_item: { text: content } }], + ...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}), + ...(nonEmptyString(runId) ? { run_id: runId.trim() } : {}), + }, + base_info: baseInfo(), + }, + }); + if (response?.ret !== undefined && response.ret !== 0) { + throw new WeixinApiError('send-rejected', '微信服务拒绝了回复消息。'); + } + return true; + }, + + async notifyStart({ baseUrl, token, signal }) { + const response = await requestJson(fetchImpl, { + method: 'POST', + baseUrl, + endpoint: 'ilink/bot/msg/notifystart', + token, + signal, + timeoutMs: 10_000, + body: { base_info: baseInfo() }, + }); + if (response?.ret !== undefined && response.ret !== 0) { + throw new WeixinApiError('start-rejected', '微信账号连接启动失败。'); + } + return response; + }, + + async notifyStop({ baseUrl, token, signal }) { + return requestJson(fetchImpl, { + method: 'POST', + baseUrl, + endpoint: 'ilink/bot/msg/notifystop', + token, + signal, + timeoutMs: 10_000, + body: { base_info: baseInfo() }, + }); + }, + }); +} + +export function extractWeixinText(message) { + for (const item of message?.item_list ?? []) { + if (item?.type === 1 && typeof item.text_item?.text === 'string') { + const text = item.text_item.text.trim(); + if (text) return text; + } + if (item?.type === 3 && typeof item.voice_item?.text === 'string') { + const text = item.voice_item.text.trim(); + if (text) return text; + } + } + return null; +} + +export function weixinMessageId(message) { + if (message?.message_id !== undefined && message.message_id !== null) { + return String(message.message_id); + } + return nonEmptyString(message?.client_id); +} + +export function splitWeixinText(text, maxChars = 4_000) { + if (text.length <= maxChars) return [text]; + const chunks = []; + let remaining = text; + while (remaining.length > maxChars) { + let splitAt = remaining.lastIndexOf('\n', maxChars); + if (splitAt < Math.floor(maxChars * 0.6)) splitAt = maxChars; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n+/, ''); + } + if (remaining) chunks.push(remaining); + return chunks; +} diff --git a/src/channels/weixin/weixin-bridge.mjs b/src/channels/weixin/weixin-bridge.mjs new file mode 100644 index 0000000..11d3b6d --- /dev/null +++ b/src/channels/weixin/weixin-bridge.mjs @@ -0,0 +1,173 @@ +import { + extractWeixinText, + splitWeixinText, + weixinMessageId, +} from './weixin-api.mjs'; + +const HELP_TEXT = [ + '微信已连接 DeepSeek Harness。', + '', + '直接发送文字或带文字识别结果的语音即可继续当前会话。', + '/new 开启一个全新会话', + '/status 检查连接状态', + '/help 显示本帮助', +].join('\n'); + +function conversationKey(userId) { + return `p2p:${userId}`; +} + +export function createWeixinBridgeStatus() { + return { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null, + }; +} + +export class WeixinHarnessBridge { + #api; + #baseUrl; + #token; + #ownerUserId; + #harness; + #state; + #status; + #logger; + #replyTimeoutMs; + #maxMessageChars; + #queues = new Map(); + + constructor({ + api, + baseUrl, + token, + ownerUserId, + harness, + state, + status = createWeixinBridgeStatus(), + logger = console, + replyTimeoutMs = 600_000, + maxMessageChars = 4_000, + }) { + if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required'); + if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required'); + if (!harness || !state) throw new TypeError('Harness client and state store are required'); + this.#api = api; + this.#baseUrl = baseUrl; + this.#token = token; + this.#ownerUserId = ownerUserId; + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#maxMessageChars = maxMessageChars; + } + + get status() { + return structuredClone(this.#status); + } + + accept(message) { + const sender = typeof message?.from_user_id === 'string' ? message.from_user_id : ''; + const previous = this.#queues.get(sender) ?? Promise.resolve(); + const current = previous + .catch(() => undefined) + .then(() => this.#process(message)) + .finally(() => { + if (this.#queues.get(sender) === current) this.#queues.delete(sender); + }); + this.#queues.set(sender, current); + return current; + } + + async waitForIdle() { + await Promise.allSettled([...this.#queues.values()]); + } + + async #process(message) { + if (message?.message_type === 2) return; + const messageId = weixinMessageId(message); + const sender = typeof message?.from_user_id === 'string' ? message.from_user_id : ''; + if (!messageId || !sender) return; + if (this.#state.hasSeen(messageId)) return; + + this.#status.messagesReceived += 1; + this.#status.lastMessageAt = new Date().toISOString(); + if (sender !== this.#ownerUserId) { + this.#status.messagesRejected += 1; + this.#status.lastRejectedAt = new Date().toISOString(); + return; + } + + const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined; + const runId = typeof message.run_id === 'string' ? message.run_id : undefined; + const text = extractWeixinText(message); + try { + if (!text) { + await this.#send(sender, '目前仅支持文字消息,以及微信已转成文字的语音消息。', contextToken, runId); + await this.#state.markSeen(messageId); + return; + } + + const command = text.trim().toLowerCase(); + if (command === '/help') { + await this.#send(sender, HELP_TEXT, contextToken, runId); + await this.#state.markSeen(messageId); + return; + } + if (command === '/status') { + await this.#harness.ensureRunning(); + await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId); + await this.#state.markSeen(messageId); + return; + } + if (command === '/new') { + await this.#state.clearSession(conversationKey(sender)); + await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId); + await this.#state.markSeen(messageId); + return; + } + + const key = conversationKey(sender); + let sessionId = this.#state.sessionFor(key); + if (!sessionId || !(await this.#harness.sessionExists(sessionId))) { + sessionId = await this.#harness.createSession(); + await this.#state.setSession(key, sessionId); + } + const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs }); + await this.#send(sender, answer, contextToken, runId); + 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-weixin] failed to process an inbound message:', error); + try { + await this.#send(sender, '消息处理失败,请稍后重试。', contextToken, runId); + await this.#state.markSeen(messageId); + } catch (sendError) { + this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError); + } + } + } + + async #send(toUserId, text, contextToken, runId) { + for (const chunk of splitWeixinText(text, this.#maxMessageChars)) { + await this.#api.sendText({ + baseUrl: this.#baseUrl, + token: this.#token, + toUserId, + text: chunk, + contextToken, + runId, + }); + } + } +} diff --git a/src/channels/weixin/weixin-controller.mjs b/src/channels/weixin/weixin-controller.mjs new file mode 100644 index 0000000..7357ce2 --- /dev/null +++ b/src/channels/weixin/weixin-controller.mjs @@ -0,0 +1,545 @@ +import { randomUUID } from 'node:crypto'; + +import { + normalizeWeixinApiBaseUrl, + WEIXIN_QR_BASE_URL, + WeixinApiError, +} from './weixin-api.mjs'; +import { deriveWeixinBotIdentity, maskWeixinAccountId } from './config-store.mjs'; + +const ACTIVE_ATTEMPT_STATES = new Set([ + 'starting', + 'pending', + 'scanned', + 'needs_verification', + 'connecting', +]); +const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']); +const QR_TTL_MS = 5 * 60_000; + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function abortError() { + return new DOMException('Provisioning was cancelled', 'AbortError'); +} + +function apiBaseFromServer(value, fallback) { + const raw = cleanString(value); + if (!raw) return normalizeWeixinApiBaseUrl(fallback); + return normalizeWeixinApiBaseUrl(raw.includes('://') ? raw : `https://${raw}`); +} + +function publicAttempt(record) { + if (!record) return null; + return { + attemptId: record.id, + status: record.state, + ...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}), + ...(record.expiresAt ? { expiresAt: record.expiresAt } : {}), + pollIntervalMs: 1_000, + ...(record.state === 'needs_verification' ? { verificationRequired: true } : {}), + ...(record.botId ? { botId: record.botId } : {}), + ...(record.alreadyConnected ? { alreadyConnected: true } : {}), + ...(record.error ? { error: structuredClone(record.error) } : {}), + }; +} + +function safeAccountError(code, message) { + return Object.freeze({ code, message }); +} + +export class WeixinController { + #api; + #credentials; + #configStore; + #createRuntime; + #deleteState; + #logger; + #runtimes = new Map(); + #errors = new Map(); + #attempts = new Map(); + #activeAttemptId = null; + #transitions = new Map(); + #revision = 0; + #closed = false; + + constructor({ + api, + credentials, + configStore, + createRuntime, + deleteState = async () => {}, + logger = console, + }) { + if (!api || typeof api.beginLogin !== 'function' || typeof api.pollLogin !== 'function') { + throw new TypeError('WeixinController requires a Weixin API client'); + } + if (!credentials + || typeof credentials.resolve !== 'function' + || typeof credentials.set !== 'function' + || typeof credentials.unset !== 'function') { + throw new TypeError('WeixinController requires the DSH credential provider'); + } + if (!configStore + || typeof configStore.list !== 'function' + || typeof configStore.save !== 'function' + || typeof configStore.remove !== 'function') { + throw new TypeError('WeixinController requires a config store'); + } + if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required'); + this.#api = api; + 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()) { + const current = this.#runtimes.get(config.botId); + if (current?.status?.ready === true) continue; + await this.#withBotTransition(config.botId, async () => { + const latest = this.#configStore.get(config.botId); + if (!latest || this.#closed) return; + try { + const token = await this.#resolveToken(latest.tokenRef); + if (!token) { + this.#errors.set( + latest.botId, + safeAccountError('missing-token', '登录凭据缺失,请移除账号后重新扫码。'), + ); + return; + } + await this.#startRuntime(latest, token); + this.#errors.delete(latest.botId); + } catch (error) { + this.#errors.set( + latest.botId, + safeAccountError('connection-failed', '微信连接未就绪,插件会自动重试。'), + ); + this.#logger.warn?.(`[dsh-weixin] account ${latest.botId} failed to initialize:`, error); + } finally { + this.#touch(); + } + }); + } + return this.status(); + } + + async startProvisioning() { + if (this.#closed) throw new Error('dsh-weixin controller is closed'); + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + + const record = { + id: randomUUID(), + state: 'starting', + createdAt: Date.now(), + expiresAt: Date.now() + QR_TTL_MS, + controller: new AbortController(), + pendingVerifyCode: null, + verifyResolve: null, + currentBaseUrl: WEIXIN_QR_BASE_URL, + error: null, + botId: null, + task: null, + }; + this.#attempts.set(record.id, record); + this.#activeAttemptId = record.id; + this.#touch(); + + try { + const localTokens = (await Promise.all( + this.#configStore.list().slice(-10).map(async (config) => this.#resolveToken(config.tokenRef)), + )).filter(Boolean); + const login = await this.#api.beginLogin({ + localTokens, + signal: record.controller.signal, + }); + this.#assertAttemptActive(record); + record.qrcode = login.qrcode; + record.verificationUrl = login.qrcodeUrl; + record.state = 'pending'; + record.expiresAt = Date.now() + QR_TTL_MS; + this.#touch(); + record.task = this.#runProvisioning(record); + return publicAttempt(record); + } catch (error) { + if (record.controller.signal.aborted) { + record.state = 'cancelled'; + record.error = safeAccountError('cancelled', '扫码绑定已取消。'); + } else { + record.state = 'failed'; + record.error = safeAccountError( + error instanceof WeixinApiError ? error.code : 'qr-start-failed', + error instanceof WeixinApiError ? error.message : '无法生成微信二维码,请稍后重试。', + ); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + throw error; + } + } + + registrationStatus(attemptId) { + return publicAttempt(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 = cleanString(verifyCode); + if (!code || !/^\d{4,8}$/.test(code)) { + throw new TypeError('Verification code must contain 4 to 8 digits'); + } + record.pendingVerifyCode = code; + record.state = 'scanned'; + record.verifyResolve?.(); + record.verifyResolve = null; + this.#touch(); + 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(); + record.verifyResolve?.(); + record.verifyResolve = null; + await record.task?.catch(() => undefined); + if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled'; + record.error ??= safeAccountError('cancelled', '扫码绑定已取消。'); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + return publicAttempt(record); + } + + async reconnectBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error('Unknown Weixin account'); + await this.#withBotTransition(botId, async () => { + const token = await this.#resolveToken(config.tokenRef); + if (!token) throw new Error('The Weixin token is missing'); + try { + await this.#startRuntime(config, token); + this.#errors.delete(botId); + } catch (error) { + this.#errors.set(botId, safeAccountError('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 Weixin account'); + await this.#withBotTransition(botId, async () => { + const previousToken = await this.#credentials.resolve(config.tokenRef).catch(() => undefined); + await this.#stopRuntime(botId); + try { + await this.#credentials.unset(config.tokenRef); + await this.#configStore.remove(botId); + } catch (error) { + if (previousToken?.value) { + await this.#credentials.set(config.tokenRef, previousToken.value).catch(() => undefined); + await this.#startRuntime(config, previousToken.value).catch(() => undefined); + } + throw new Error('Unable to remove the Weixin account safely.', { cause: error }); + } + try { + await this.#deleteState({ botId, config }); + } catch (error) { + this.#logger.warn?.(`[dsh-weixin] account ${botId} state cleanup failed:`, error); + } + this.#errors.delete(botId); + this.#touch(); + }); + return this.status(); + } + + status() { + const accounts = this.#configStore.list().map((config) => { + const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null; + const connected = runtimeStatus?.ready === true + && runtimeStatus.weixinConnectionState === 'connected' + && runtimeStatus.harnessReachable === true; + const state = connected + ? 'connected' + : runtimeStatus?.weixinConnectionState === 'connecting' + ? 'connecting' + : this.#errors.has(config.botId) || runtimeStatus?.weixinConnectionState === 'failed' + ? 'error' + : 'offline'; + const error = this.#errors.get(config.botId) ?? (state === 'error' + ? safeAccountError('connection-failed', '微信连接未就绪,插件会自动重试。') + : null); + return { + botId: config.botId, + state, + connected, + configured: true, + bot: { + name: '微信机器人', + accountIdMasked: maskWeixinAccountId(config.accountId), + }, + health: { + status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline', + summary: connected + ? '微信消息长轮询运行正常' + : state === 'error' + ? '微信连接未就绪,插件会自动重试' + : '微信连接当前离线', + lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null, + }, + stats: { + messagesReceived: runtimeStatus?.messagesReceived ?? 0, + messagesReplied: runtimeStatus?.messagesReplied ?? 0, + }, + error: error ? structuredClone(error) : null, + }; + }); + const connectedCount = accounts.filter((account) => account.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' + : accounts.length === 0 + ? 'disconnected' + : connectedCount === accounts.length + ? 'connected' + : connectedCount > 0 + ? 'degraded' + : 'offline', + bots: accounts, + totals: { configured: accounts.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 #runProvisioning(record) { + try { + while (!record.controller.signal.aborted && Date.now() < record.expiresAt) { + if (record.state === 'needs_verification' && !record.pendingVerifyCode) { + await new Promise((resolve) => { + record.verifyResolve = resolve; + if (record.controller.signal.aborted) resolve(); + }); + record.verifyResolve = null; + this.#assertAttemptActive(record); + } + + const response = await this.#api.pollLogin({ + qrcode: record.qrcode, + baseUrl: record.currentBaseUrl, + verifyCode: record.pendingVerifyCode, + signal: record.controller.signal, + }); + this.#assertAttemptActive(record); + if (response.status === 'wait') { + record.state = 'pending'; + } else if (response.status === 'scaned') { + record.pendingVerifyCode = null; + record.state = 'scanned'; + } else if (response.status === 'need_verifycode') { + record.pendingVerifyCode = null; + record.state = 'needs_verification'; + } else if (response.status === 'verify_code_blocked') { + record.state = 'failed'; + record.error = safeAccountError('verification-blocked', '配对码多次错误,请重新生成二维码。'); + break; + } else if (response.status === 'expired') { + record.state = 'expired'; + record.error = safeAccountError('expired', '二维码已过期,请重新生成。'); + break; + } else if (response.status === 'scaned_but_redirect') { + record.currentBaseUrl = apiBaseFromServer(response.redirect_host, record.currentBaseUrl); + record.state = 'scanned'; + } else if (response.status === 'binded_redirect') { + const existing = this.#configStore.list().find( + (config) => this.#runtimes.get(config.botId)?.status?.ready === true, + ) ?? this.#configStore.list()[0]; + if (!existing) { + record.state = 'failed'; + record.error = safeAccountError('already-bound', '该微信账号已绑定,但本机没有可恢复的凭据。'); + } else { + record.state = 'connected'; + record.botId = existing.botId; + record.alreadyConnected = true; + } + break; + } else if (response.status === 'confirmed') { + const token = cleanString(response.bot_token); + const accountId = cleanString(response.ilink_bot_id); + const ownerUserId = cleanString(response.ilink_user_id); + if (!token || !accountId || !ownerUserId) { + throw new WeixinApiError('incomplete-login', '微信授权成功,但返回的账号凭据不完整。'); + } + record.state = 'connecting'; + this.#touch(); + const baseUrl = apiBaseFromServer(response.baseurl, record.currentBaseUrl); + record.botId = await this.#activateAccount(record, { + token, + accountId, + ownerUserId, + baseUrl, + }); + record.state = 'connected'; + record.error = null; + break; + } + this.#touch(); + } + if (!record.controller.signal.aborted && Date.now() >= record.expiresAt + && !TERMINAL_ATTEMPT_STATES.has(record.state)) { + record.state = 'expired'; + record.error = safeAccountError('expired', '二维码已过期,请重新生成。'); + } + } catch (error) { + if (record.controller.signal.aborted || error?.name === 'AbortError') { + record.state = 'cancelled'; + record.error = safeAccountError('cancelled', '扫码绑定已取消。'); + } else { + record.state = 'failed'; + record.error = safeAccountError( + error instanceof WeixinApiError ? error.code : 'activation-failed', + error instanceof WeixinApiError + ? error.message + : '微信已授权,但无法保存凭据或启动消息连接。', + ); + this.#logger.error?.('[dsh-weixin] provisioning failed:', error); + } + } finally { + record.pendingVerifyCode = null; + record.verifyResolve?.(); + record.verifyResolve = null; + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + this.#pruneAttempts(); + } + } + + async #activateAccount(record, { token, accountId, ownerUserId, baseUrl }) { + const identity = deriveWeixinBotIdentity(accountId); + const previousConfig = this.#configStore.getByAccountId(accountId); + const config = { + botId: identity.botId, + accountId, + tokenRef: identity.tokenRef, + ownerUserId, + baseUrl, + createdAt: previousConfig?.createdAt ?? new Date().toISOString(), + connectedAt: new Date().toISOString(), + }; + const previousToken = await this.#credentials.resolve(identity.tokenRef).catch(() => undefined); + + return this.#withBotTransition(identity.botId, async () => { + await this.#credentials.set(identity.tokenRef, token); + try { + this.#assertAttemptActive(record); + await this.#configStore.save(config); + this.#assertAttemptActive(record); + await this.#startRuntime(config, token); + this.#assertAttemptActive(record); + this.#errors.delete(identity.botId); + this.#touch(); + return identity.botId; + } catch (error) { + await this.#stopRuntime(identity.botId); + if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined); + else if (this.#configStore.get(identity.botId)) { + await this.#configStore.remove(identity.botId).catch(() => undefined); + } + await this.#restoreCredential(identity.tokenRef, previousToken); + if (previousConfig && previousToken?.value) { + await this.#startRuntime(previousConfig, previousToken.value).catch(() => undefined); + } + throw error; + } + }); + } + + async #startRuntime(config, token) { + await this.#stopRuntime(config.botId); + const runtime = await this.#createRuntime({ botId: config.botId, config, token }); + if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') { + throw new TypeError('createRuntime returned an invalid Weixin runtime'); + } + try { + await runtime.start(); + this.#runtimes.set(config.botId, runtime); + } catch (error) { + await runtime.stop().catch(() => undefined); + throw error; + } + } + + async #stopRuntime(botId) { + const runtime = this.#runtimes.get(botId); + this.#runtimes.delete(botId); + await runtime?.stop().catch((error) => { + this.#logger.warn?.(`[dsh-weixin] account ${botId} failed to stop cleanly:`, error); + }); + } + + async #resolveToken(ref) { + const result = await this.#credentials.resolve(ref).catch(() => undefined); + return cleanString(result?.value); + } + + async #restoreCredential(ref, previous) { + try { + if (previous?.value) await this.#credentials.set(ref, previous.value); + else await this.#credentials.unset(ref); + } catch (error) { + this.#logger.error?.(`[dsh-weixin] failed to restore credential ${ref}:`, error); + } + } + + #assertAttemptActive(record) { + if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) throw abortError(); + } + + #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; + } + + #pruneAttempts() { + for (const [id, record] of this.#attempts) { + if (id !== this.#activeAttemptId && TERMINAL_ATTEMPT_STATES.has(record.state) + && this.#attempts.size > 16) { + this.#attempts.delete(id); + } + } + } + + #touch() { + this.#revision += 1; + } +} diff --git a/src/channels/weixin/weixin-runtime.mjs b/src/channels/weixin/weixin-runtime.mjs new file mode 100644 index 0000000..19e85bd --- /dev/null +++ b/src/channels/weixin/weixin-runtime.mjs @@ -0,0 +1,204 @@ +import { WeixinApiError } from './weixin-api.mjs'; +import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs'; + +function delay(ms, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + return; + } + const finish = () => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }; + const timer = setTimeout(finish, ms); + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +export function createWeixinRuntimeStatus() { + return { + startedAt: null, + ready: false, + weixinConnectionState: 'idle', + harnessReachable: false, + lastCheckedAt: null, + lastError: null, + ...createWeixinBridgeStatus(), + }; +} + +export class WeixinRuntime { + #api; + #config; + #token; + #harness; + #state; + #logger; + #replyTimeoutMs; + #maxMessageChars; + #status = createWeixinRuntimeStatus(); + #bridge = null; + #abortController = null; + #monitor = null; + #starting = null; + + constructor({ + api, + config, + token, + harness, + state, + logger = console, + replyTimeoutMs = 600_000, + maxMessageChars = 4_000, + }) { + if (!api || !config || !token || !harness || !state) { + throw new TypeError('WeixinRuntime requires API, account, token, Harness, and state'); + } + this.#api = api; + this.#config = config; + this.#token = token; + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#maxMessageChars = maxMessageChars; + } + + get status() { + return structuredClone(this.#status); + } + + async start() { + if (this.#status.ready && this.#monitor) return this.status; + if (this.#starting) return this.#starting; + this.#starting = this.#start().finally(() => { + this.#starting = null; + }); + return this.#starting; + } + + async #start() { + await this.stop(); + this.#status.startedAt = new Date().toISOString(); + this.#status.weixinConnectionState = 'connecting'; + this.#status.lastError = null; + try { + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + await this.#api.notifyStart({ + baseUrl: this.#config.baseUrl, + token: this.#token, + }); + this.#bridge = new WeixinHarnessBridge({ + api: this.#api, + baseUrl: this.#config.baseUrl, + token: this.#token, + ownerUserId: this.#config.ownerUserId, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs, + maxMessageChars: this.#maxMessageChars, + }); + this.#abortController = new AbortController(); + this.#status.ready = true; + this.#status.weixinConnectionState = 'connected'; + this.#status.lastCheckedAt = Date.now(); + const signal = this.#abortController.signal; + this.#monitor = this.#runMonitor(signal).catch((error) => { + if (signal.aborted) return; + this.#status.ready = false; + this.#status.weixinConnectionState = 'failed'; + this.#status.lastError = error?.message ?? String(error); + this.#logger.error?.(`[dsh-weixin] account ${this.#config.botId} monitor stopped:`, error); + }); + return this.status; + } catch (error) { + this.#status.ready = false; + this.#status.weixinConnectionState = 'failed'; + this.#status.lastError = error?.message ?? String(error); + throw error; + } + } + + async #runMonitor(signal) { + let consecutiveFailures = 0; + while (!signal.aborted) { + try { + const response = await this.#api.getUpdates({ + baseUrl: this.#config.baseUrl, + token: this.#token, + getUpdatesBuf: this.#state.getUpdatesBuf(), + signal, + }); + if (signal.aborted) return; + const rejected = (response?.ret !== undefined && response.ret !== 0) + || (response?.errcode !== undefined && response.errcode !== 0); + if (rejected) { + const code = response.errcode ?? response.ret; + throw new WeixinApiError( + code === -14 ? 'stale-token' : 'updates-rejected', + code === -14 ? '微信登录凭据已失效,请移除账号后重新扫码。' : '微信消息同步请求被拒绝。', + ); + } + consecutiveFailures = 0; + this.#status.ready = true; + this.#status.weixinConnectionState = 'connected'; + this.#status.lastCheckedAt = Date.now(); + this.#status.lastError = null; + + for (const message of response?.msgs ?? []) { + await this.#bridge.accept(message); + } + if (typeof response?.get_updates_buf === 'string' && response.get_updates_buf) { + await this.#state.setGetUpdatesBuf(response.get_updates_buf); + } + } catch (error) { + if (signal.aborted) return; + consecutiveFailures += 1; + this.#status.lastError = error?.message ?? String(error); + this.#logger.warn?.( + `[dsh-weixin] account ${this.#config.botId} poll failed (${consecutiveFailures}/3):`, + error, + ); + if (error instanceof WeixinApiError && error.code === 'stale-token') throw error; + if (consecutiveFailures >= 3) throw error; + await delay(Math.min(2_000 * (2 ** (consecutiveFailures - 1)), 10_000), signal); + } + } + } + + async stop() { + const monitor = this.#monitor; + const bridge = this.#bridge; + const wasStarted = Boolean(this.#abortController || monitor || this.#status.ready); + this.#abortController?.abort(); + this.#abortController = null; + this.#monitor = null; + await monitor?.catch(() => undefined); + await bridge?.waitForIdle(); + this.#bridge = null; + if (wasStarted) { + try { + await this.#api.notifyStop({ + baseUrl: this.#config.baseUrl, + token: this.#token, + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + this.#logger.warn?.(`[dsh-weixin] account ${this.#config.botId} stop notification failed:`, error); + } + } + this.#status.ready = false; + this.#status.weixinConnectionState = 'idle'; + return this.status; + } +} diff --git a/test/channels/dingtalk/client-api.test.mjs b/test/channels/dingtalk/client-api.test.mjs new file mode 100644 index 0000000..248d260 --- /dev/null +++ b/test/channels/dingtalk/client-api.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + DINGTALK_ENDPOINTS, + DINGTALK_RPC_CHANNEL, + formatRemaining, + normalizeProvisioning, + normalizeSnapshot, + presentError, + safeQrSource, + unwrapRpcResult, +} from '../../../plugin-src/client/channels/dingtalk/api.js'; + +test('client exposes the fixed DingTalk RPC channel and endpoint names', () => { + assert.equal(DINGTALK_RPC_CHANNEL, '/dingtalk'); + assert.deepEqual(DINGTALK_ENDPOINTS, { + status: 'connection.status', + beginProvisioning: 'provision.begin', + pollProvisioning: 'provision.poll', + cancelProvisioning: 'provision.cancel', + reconnectBot: 'bot.reconnect', + deleteBot: 'bot.delete', + }); +}); + +test('RPC envelopes are required and sensitive error details are replaced', () => { + assert.equal(unwrapRpcResult({ ok: true, value: { ready: true } }).ready, true); + assert.throws( + () => unwrapRpcResult({ value: {} }), + /无法识别/, + ); + assert.throws( + () => unwrapRpcResult({ + ok: false, + error: { + code: 'clientSecret=should-not-escape', + message: 'clientSecret=super-secret-value', + }, + }), + (error) => error.code === 'DINGTALK_RPC_ERROR' + && error.message === '钉钉操作失败' + && !error.message.includes('super-secret-value'), + ); +}); + +test('QR images accept only bounded base64 PNG or WebP data URLs', () => { + assert.equal(safeQrSource('data:image/png;base64,AAAA'), 'data:image/png;base64,AAAA'); + assert.equal(safeQrSource('data:image/webp;base64,AAAA'), 'data:image/webp;base64,AAAA'); + assert.equal(safeQrSource('data:image/svg+xml;base64,AAAA'), undefined); + assert.equal(safeQrSource('data:image/png;base64,AAAA\n