mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-16 04:03:18 +08:00
Internalize all IM channel implementations
This commit is contained in:
parent
8996476693
commit
bd469f58f8
95 changed files with 25012 additions and 100 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ coverage/
|
||||||
!.env.example
|
!.env.example
|
||||||
*.tgz
|
*.tgz
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ npx -y github:xmanrui/dsh-im install
|
||||||
## 设计
|
## 设计
|
||||||
|
|
||||||
- Harness 中只注册一个「IM机器人」设置页;
|
- Harness 中只注册一个「IM机器人」设置页;
|
||||||
|
- 飞书、微信和钉钉的 Host、客户端与运行时源码都在本仓库维护,不依赖外部 `dsh-feishu`、`dsh-weixin` 或 `dsh-dingtalk` 插件;
|
||||||
- 左侧使用渠道 Logo 切换微信、飞书和钉钉,不使用启用/停用开关;
|
- 左侧使用渠道 Logo 切换微信、飞书和钉钉,不使用启用/停用开关;
|
||||||
- 三个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
- 三个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
||||||
- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret` 或原始 staff ID。
|
- 浏览器只获得二维码和脱敏状态,不获得 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
|
## Design
|
||||||
|
|
||||||
- Registers a single **IM Bot** settings page in Harness.
|
- 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.
|
- 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.
|
- 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.
|
- Sends only QR codes and redacted status data to the browser, never App Secrets, `bot_token`, DingTalk `client_secret`, or raw staff IDs.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
# Third-party notices
|
# 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ __export(index_exports, {
|
||||||
module.exports = __toCommonJS(index_exports);
|
module.exports = __toCommonJS(index_exports);
|
||||||
var React5 = __toESM(require("react"), 1);
|
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_RPC_CHANNEL = "/dingtalk";
|
||||||
var DINGTALK_ENDPOINTS = Object.freeze({
|
var DINGTALK_ENDPOINTS = Object.freeze({
|
||||||
status: "connection.status",
|
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")}`;
|
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);
|
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 DINGTALK_STYLE_ID = "xmanrui-dsh-dingtalk-settings";
|
||||||
var CSS = String.raw`
|
var CSS = String.raw`
|
||||||
.ddt-page {
|
.ddt-page {
|
||||||
|
|
@ -367,7 +367,7 @@ function installDingtalkStyles() {
|
||||||
return () => style.remove();
|
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 h = React.createElement;
|
||||||
var ACTIVE_PROVISION_STATES = /* @__PURE__ */ new Set(["pending", "scanned", "authorizing", "creating", "connecting"]);
|
var ACTIVE_PROVISION_STATES = /* @__PURE__ */ new Set(["pending", "scanned", "authorizing", "creating", "connecting"]);
|
||||||
function DingtalkIcon({ size = 28 }) {
|
function DingtalkIcon({ size = 28 }) {
|
||||||
|
|
|
||||||
7982
lib/index.js
7982
lib/index.js
File diff suppressed because it is too large
Load diff
115
package-lock.json
generated
115
package-lock.json
generated
|
|
@ -9,9 +9,9 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz",
|
"@larksuiteoapi/node-sdk": "1.73.0",
|
||||||
"@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz",
|
"dingtalk-stream": "2.1.4",
|
||||||
"@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz"
|
"qrcode": "1.5.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"dsh-im": "bin/dsh-im.mjs"
|
"dsh-im": "bin/dsh-im.mjs"
|
||||||
|
|
@ -19,7 +19,8 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "0.25.9",
|
"esbuild": "0.25.9",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"react-test-renderer": "18.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.19"
|
"node": ">=22.19"
|
||||||
|
|
@ -548,54 +549,6 @@
|
||||||
"undici-types": "~8.3.0"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
|
@ -781,18 +734,6 @@
|
||||||
"ws": "^8.13.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|
@ -1169,6 +1110,16 @@
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|
@ -1328,6 +1279,42 @@
|
||||||
"react": "^18.3.1"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -50,20 +50,21 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node plugin-src/client/build.mjs && node plugin-src/host/build.mjs",
|
"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"
|
"check": "npm test && npm run build && node scripts/verify-package.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.19"
|
"node": ">=22.19"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xmanrui/dsh-dingtalk": "https://github.com/xmanrui/dsh-dingtalk/archive/05f5a319b52ef7f0952baf5d603ebc144657262a.tar.gz",
|
"@larksuiteoapi/node-sdk": "1.73.0",
|
||||||
"@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz",
|
"dingtalk-stream": "2.1.4",
|
||||||
"@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz"
|
"qrcode": "1.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "0.25.9",
|
"esbuild": "0.25.9",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"react-test-renderer": "18.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
202
plugin-src/client/channels/dingtalk/api.js
Normal file
202
plugin-src/client/channels/dingtalk/api.js
Normal file
|
|
@ -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')}`;
|
||||||
|
}
|
||||||
661
plugin-src/client/channels/dingtalk/index.js
Normal file
661
plugin-src/client/channels/dingtalk/index.js
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
141
plugin-src/client/channels/dingtalk/styles.js
Normal file
141
plugin-src/client/channels/dingtalk/styles.js
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { DINGTALK_RPC_CHANNEL } from '@xmanrui/dsh-dingtalk/client-api';
|
import { DINGTALK_RPC_CHANNEL } from './channels/dingtalk/api.js';
|
||||||
import { DingtalkSettingsTab } from '@xmanrui/dsh-dingtalk/client-source';
|
import { DingtalkSettingsTab } from './channels/dingtalk/index.js';
|
||||||
import { FeishuSettingsTab } from './channels/feishu/index.js';
|
import { FeishuSettingsTab } from './channels/feishu/index.js';
|
||||||
import { FEISHU_RPC_CHANNEL } from './channels/feishu/api.js';
|
import { FEISHU_RPC_CHANNEL } from './channels/feishu/api.js';
|
||||||
import { installFeishuStyles } from './channels/feishu/styles.js';
|
import { installFeishuStyles } from './channels/feishu/styles.js';
|
||||||
|
|
|
||||||
130
plugin-src/host/channels/dingtalk/connection-supervisor.mjs
Normal file
130
plugin-src/host/channels/dingtalk/connection-supervisor.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
32
plugin-src/host/channels/dingtalk/index.mjs
Normal file
32
plugin-src/host/channels/dingtalk/index.mjs
Normal file
|
|
@ -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';
|
||||||
122
plugin-src/host/channels/dingtalk/production.mjs
Normal file
122
plugin-src/host/channels/dingtalk/production.mjs
Normal file
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
221
plugin-src/host/channels/dingtalk/rpc.mjs
Normal file
221
plugin-src/host/channels/dingtalk/rpc.mjs
Normal file
|
|
@ -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' },
|
||||||
|
);
|
||||||
|
}
|
||||||
167
plugin-src/host/channels/feishu/connection-supervisor.mjs
Normal file
167
plugin-src/host/channels/feishu/connection-supervisor.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
172
plugin-src/host/channels/feishu/controller.mjs
Normal file
172
plugin-src/host/channels/feishu/controller.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
84
plugin-src/host/channels/feishu/credential-store.mjs
Normal file
84
plugin-src/host/channels/feishu/credential-store.mjs
Normal file
|
|
@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
66
plugin-src/host/channels/feishu/index.mjs
Normal file
66
plugin-src/host/channels/feishu/index.mjs
Normal file
|
|
@ -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';
|
||||||
138
plugin-src/host/channels/feishu/production.mjs
Normal file
138
plugin-src/host/channels/feishu/production.mjs
Normal file
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
434
plugin-src/host/channels/feishu/rpc.mjs
Normal file
434
plugin-src/host/channels/feishu/rpc.mjs
Normal file
|
|
@ -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<RpcResult>. */
|
||||||
|
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' },
|
||||||
|
);
|
||||||
|
}
|
||||||
127
plugin-src/host/channels/weixin/connection-supervisor.mjs
Normal file
127
plugin-src/host/channels/weixin/connection-supervisor.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
32
plugin-src/host/channels/weixin/index.mjs
Normal file
32
plugin-src/host/channels/weixin/index.mjs
Normal file
|
|
@ -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';
|
||||||
119
plugin-src/host/channels/weixin/production.mjs
Normal file
119
plugin-src/host/channels/weixin/production.mjs
Normal file
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
177
plugin-src/host/channels/weixin/rpc.mjs
Normal file
177
plugin-src/host/channels/weixin/rpc.mjs
Normal file
|
|
@ -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' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { apply as applyDingtalk } from '@xmanrui/dsh-dingtalk';
|
import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
|
||||||
import { apply as applyFeishu } from '@xmanrui/dsh-feishu';
|
import { apply as applyFeishu } from './channels/feishu/index.mjs';
|
||||||
import { apply as applyWeixin } from '@xmanrui/dsh-weixin';
|
import { apply as applyWeixin } from './channels/weixin/index.mjs';
|
||||||
|
|
||||||
export const name = 'dsh-im-host';
|
export const name = 'dsh-im-host';
|
||||||
export const inject = ['connection', 'credentials', 'webServer'];
|
export const inject = ['connection', 'credentials', 'webServer'];
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,24 @@ const required = [
|
||||||
'cordis.patch.yml',
|
'cordis.patch.yml',
|
||||||
'README.md',
|
'README.md',
|
||||||
'THIRD_PARTY_NOTICES.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))));
|
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/client.js'), 'utf8'),
|
||||||
readFile(resolve(root, 'lib/index.js'), 'utf8'),
|
readFile(resolve(root, 'lib/index.js'), 'utf8'),
|
||||||
readFile(resolve(root, 'cordis.patch.yml'), 'utf8'),
|
readFile(resolve(root, 'cordis.patch.yml'), 'utf8'),
|
||||||
readFile(resolve(root, 'package.json'), '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')),
|
stat(resolve(root, 'bin/dsh-im.mjs')),
|
||||||
]);
|
]);
|
||||||
const manifest = JSON.parse(manifestText);
|
const manifest = JSON.parse(manifestText);
|
||||||
|
|
@ -37,19 +47,35 @@ if (!client.includes('container-type: inline-size')
|
||||||
|| !client.includes('@container (max-width: 680px)')) {
|
|| !client.includes('@container (max-width: 680px)')) {
|
||||||
throw new Error('client bundle does not contain the narrow-panel DingTalk QR layout');
|
throw new Error('client bundle does not contain the narrow-panel DingTalk QR layout');
|
||||||
}
|
}
|
||||||
if (!host.includes('@xmanrui/dsh-feishu')
|
for (const marker of ['/feishu', '/weixin', '/dingtalk']) {
|
||||||
|| !host.includes('@xmanrui/dsh-weixin')
|
if (!host.includes(marker)) {
|
||||||
|| !host.includes('@xmanrui/dsh-dingtalk')) {
|
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
|
||||||
throw new Error('host bundle does not compose all three channel providers');
|
}
|
||||||
|
}
|
||||||
|
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)) {
|
if (!patch.includes("name: '@xmanrui/dsh-im'") || /dsh-(?:feishu|weixin|dingtalk)/.test(patch)) {
|
||||||
throw new Error('bundle patch must activate only dsh-im');
|
throw new Error('bundle patch must activate only dsh-im');
|
||||||
}
|
}
|
||||||
for (const name of ['@xmanrui/dsh-feishu', '@xmanrui/dsh-weixin', '@xmanrui/dsh-dingtalk']) {
|
for (const name of ['@xmanrui/dsh-feishu', '@xmanrui/dsh-weixin', '@xmanrui/dsh-dingtalk']) {
|
||||||
const spec = manifest.dependencies?.[name];
|
if (manifest.dependencies?.[name]) {
|
||||||
if (typeof spec !== 'string'
|
throw new Error(`${name} must not remain an external dependency`);
|
||||||
|| !/^https:\/\/github\.com\/[^/]+\/[^/]+\/archive\/[0-9a-f]{40}\.tar\.gz$/.test(spec)) {
|
}
|
||||||
throw new Error(`${name} must use a pinned public HTTPS archive`);
|
}
|
||||||
|
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');
|
if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable');
|
||||||
|
|
|
||||||
282
src/channels/dingtalk/config-store.mjs
Normal file
282
src/channels/dingtalk/config-store.mjs
Normal file
|
|
@ -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<DingtalkConfigStore>} 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<object>} 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<object>} 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<object|null>} 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 };
|
||||||
237
src/channels/dingtalk/device-auth.mjs
Normal file
237
src/channels/dingtalk/device-auth.mjs
Normal file
|
|
@ -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<object>} 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<object>} 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 };
|
||||||
579
src/channels/dingtalk/dingtalk-api.mjs
Normal file
579
src/channels/dingtalk/dingtalk-api.mjs
Normal file
|
|
@ -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}<br>`;
|
||||||
|
}).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;
|
||||||
281
src/channels/dingtalk/dingtalk-bridge.mjs
Normal file
281
src/channels/dingtalk/dingtalk-bridge.mjs
Normal file
|
|
@ -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;
|
||||||
233
src/channels/dingtalk/dingtalk-card-stream.mjs
Normal file
233
src/channels/dingtalk/dingtalk-card-stream.mjs
Normal file
|
|
@ -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<boolean>, push(progressText: string): void, finish(finalText: string): Promise<boolean>}}
|
||||||
|
* 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 });
|
||||||
|
}
|
||||||
708
src/channels/dingtalk/dingtalk-controller.mjs
Normal file
708
src/channels/dingtalk/dingtalk-controller.mjs
Normal file
|
|
@ -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 };
|
||||||
358
src/channels/dingtalk/dingtalk-runtime.mjs
Normal file
358
src/channels/dingtalk/dingtalk-runtime.mjs
Normal file
|
|
@ -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;
|
||||||
299
src/channels/dingtalk/harness-client.mjs
Normal file
299
src/channels/dingtalk/harness-client.mjs
Normal file
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/channels/dingtalk/state-store.mjs
Normal file
212
src/channels/dingtalk/state-store.mjs
Normal file
|
|
@ -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;
|
||||||
216
src/channels/feishu/bridge.mjs
Normal file
216
src/channels/feishu/bridge.mjs
Normal file
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/channels/feishu/config.mjs
Normal file
71
src/channels/feishu/config.mjs
Normal file
|
|
@ -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,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
51
src/channels/feishu/feishu-app.mjs
Normal file
51
src/channels/feishu/feishu-app.mjs
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
153
src/channels/feishu/feishu-channel.mjs
Normal file
153
src/channels/feishu/feishu-channel.mjs
Normal file
|
|
@ -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 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
222
src/channels/feishu/feishu-runtime.mjs
Normal file
222
src/channels/feishu/feishu-runtime.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
268
src/channels/feishu/harness-client.mjs
Normal file
268
src/channels/feishu/harness-client.mjs
Normal file
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/channels/feishu/message-utils.mjs
Normal file
50
src/channels/feishu/message-utils.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
616
src/channels/feishu/multi-bot-controller.mjs
Normal file
616
src/channels/feishu/multi-bot-controller.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/channels/feishu/plugin-config-store.mjs
Normal file
204
src/channels/feishu/plugin-config-store.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
248
src/channels/feishu/plugin-controller.mjs
Normal file
248
src/channels/feishu/plugin-controller.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
345
src/channels/feishu/registration-manager.mjs
Normal file
345
src/channels/feishu/registration-manager.mjs
Normal file
|
|
@ -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;
|
||||||
71
src/channels/feishu/state-store.mjs
Normal file
71
src/channels/feishu/state-store.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/channels/weixin/config-store.mjs
Normal file
182
src/channels/weixin/config-store.mjs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
259
src/channels/weixin/harness-client.mjs
Normal file
259
src/channels/weixin/harness-client.mjs
Normal file
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/channels/weixin/state-store.mjs
Normal file
112
src/channels/weixin/state-store.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
319
src/channels/weixin/weixin-api.mjs
Normal file
319
src/channels/weixin/weixin-api.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
173
src/channels/weixin/weixin-bridge.mjs
Normal file
173
src/channels/weixin/weixin-bridge.mjs
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
545
src/channels/weixin/weixin-controller.mjs
Normal file
545
src/channels/weixin/weixin-controller.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/channels/weixin/weixin-runtime.mjs
Normal file
204
src/channels/weixin/weixin-runtime.mjs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
test/channels/dingtalk/client-api.test.mjs
Normal file
118
test/channels/dingtalk/client-api.test.mjs
Normal file
|
|
@ -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<script>'), undefined);
|
||||||
|
assert.equal(safeQrSource('javascript:alert(1)'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provisioning keeps only the browser-safe attempt projection', () => {
|
||||||
|
const now = 1_000;
|
||||||
|
const value = normalizeProvisioning({
|
||||||
|
attemptId: 'attempt-safe',
|
||||||
|
status: 'pending',
|
||||||
|
expiresIn: 90,
|
||||||
|
pollIntervalMs: 10,
|
||||||
|
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||||
|
verificationUrl: 'https://login.dingtalk.com/device',
|
||||||
|
deviceCode: 'raw-device-code',
|
||||||
|
clientSecret: 'raw-client-secret',
|
||||||
|
secretRef: 'credential-ref',
|
||||||
|
}, now);
|
||||||
|
assert.deepEqual(value, {
|
||||||
|
attemptId: 'attempt-safe',
|
||||||
|
status: 'pending',
|
||||||
|
expiresAt: 91_000,
|
||||||
|
pollIntervalMs: 1_000,
|
||||||
|
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||||
|
});
|
||||||
|
assert.equal(normalizeProvisioning({ attemptId: 'safe', status: 'unknown' }, now).status, 'failed');
|
||||||
|
assert.doesNotMatch(JSON.stringify(value), /raw-device|raw-client|credential-ref/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot derives totals and exposes only browser-safe bot state', () => {
|
||||||
|
const snapshot = normalizeSnapshot({
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: 7,
|
||||||
|
totals: { configured: 99, connected: 99 },
|
||||||
|
bots: [{
|
||||||
|
botId: 'bot-safe',
|
||||||
|
connected: true,
|
||||||
|
state: 'offline',
|
||||||
|
bot: {
|
||||||
|
name: '研发助手',
|
||||||
|
clientIdMasked: 'ding••••6f2a',
|
||||||
|
clientId: 'raw-client-id',
|
||||||
|
clientSecret: 'raw-client-secret',
|
||||||
|
},
|
||||||
|
secretRef: 'credential-ref',
|
||||||
|
deviceCode: 'device-code',
|
||||||
|
health: { status: 'healthy', summary: '连接正常', lastCheckedAt: 123 },
|
||||||
|
stats: { messagesReceived: 8, messagesReplied: 6 },
|
||||||
|
senders: { pending: [{ senderId: 'raw-staff-id' }] },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(snapshot.totals, { configured: 1, connected: 1 });
|
||||||
|
assert.equal(snapshot.bots[0].state, 'connected');
|
||||||
|
assert.equal('senders' in snapshot.bots[0], false);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
/raw-client-id|raw-client-secret|credential-ref|device-code|raw-staff-id/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presentation helpers redact sensitive messages and format countdowns', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
presentError({ code: 'UPSTREAM_FAILED', message: 'accessToken: visible-value' }),
|
||||||
|
{ code: 'UPSTREAM_FAILED', message: '钉钉操作失败,请稍后重试' },
|
||||||
|
);
|
||||||
|
assert.equal(formatRemaining(61_000), '01:01');
|
||||||
|
assert.equal(formatRemaining(-1), '00:00');
|
||||||
|
});
|
||||||
272
test/channels/dingtalk/client-lifecycle.test.mjs
Normal file
272
test/channels/dingtalk/client-lifecycle.test.mjs
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as React from 'react';
|
||||||
|
import TestRenderer from 'react-test-renderer';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DINGTALK_ENDPOINTS,
|
||||||
|
} from '../../../plugin-src/client/channels/dingtalk/api.js';
|
||||||
|
import { DingtalkSettingsTab } from '../../../plugin-src/client/channels/dingtalk/index.js';
|
||||||
|
|
||||||
|
const { act, create } = TestRenderer;
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
let reject;
|
||||||
|
const promise = new Promise((onResolve, onReject) => {
|
||||||
|
resolve = onResolve;
|
||||||
|
reject = onReject;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(value) {
|
||||||
|
return { ok: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function provisioning(attemptId, overrides = {}) {
|
||||||
|
return {
|
||||||
|
attemptId,
|
||||||
|
status: 'pending',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
pollIntervalMs: 1_000,
|
||||||
|
qrCodeDataUrl: `data:image/png;base64,${attemptId === 'attempt-old' ? 'QUFBQQ==' : 'QkJCQg=='}`,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: 1,
|
||||||
|
state: 'disconnected',
|
||||||
|
bots: [],
|
||||||
|
provisioning: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBrowserClock() {
|
||||||
|
let nextId = 1;
|
||||||
|
const timeouts = new Map();
|
||||||
|
const intervals = new Map();
|
||||||
|
const frames = new Map();
|
||||||
|
const cancelledFrames = [];
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
|
||||||
|
globalThis.window = {
|
||||||
|
setTimeout(callback, delay) {
|
||||||
|
const id = nextId++;
|
||||||
|
timeouts.set(id, { callback, delay });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearTimeout(id) {
|
||||||
|
timeouts.delete(id);
|
||||||
|
},
|
||||||
|
setInterval(callback, delay) {
|
||||||
|
const id = nextId++;
|
||||||
|
intervals.set(id, { callback, delay });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearInterval(id) {
|
||||||
|
intervals.delete(id);
|
||||||
|
},
|
||||||
|
requestAnimationFrame(callback) {
|
||||||
|
const id = nextId++;
|
||||||
|
frames.set(id, callback);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
cancelAnimationFrame(id) {
|
||||||
|
cancelledFrames.push(id);
|
||||||
|
frames.delete(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cancelledFrames,
|
||||||
|
frames,
|
||||||
|
intervals,
|
||||||
|
timeouts,
|
||||||
|
runInterval(delay) {
|
||||||
|
const entry = [...intervals.values()].find((candidate) => candidate.delay === delay);
|
||||||
|
assert.ok(entry, `missing ${delay}ms interval`);
|
||||||
|
return entry.callback();
|
||||||
|
},
|
||||||
|
runTimeout(delay) {
|
||||||
|
const match = [...timeouts.entries()].find(([, candidate]) => candidate.delay === delay);
|
||||||
|
assert.ok(match, `missing ${delay}ms timeout`);
|
||||||
|
const [id, entry] = match;
|
||||||
|
timeouts.delete(id);
|
||||||
|
return entry.callback();
|
||||||
|
},
|
||||||
|
restore() {
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
for (let index = 0; index < 6; index += 1) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeText(node) {
|
||||||
|
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||||
|
if (!node) return '';
|
||||||
|
const children = Array.isArray(node) ? node : node.children;
|
||||||
|
return Array.isArray(children) ? children.map(nodeText).join('') : nodeText(children);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findButton(renderer, label) {
|
||||||
|
const button = renderer.root.findAllByType('button')
|
||||||
|
.find((candidate) => nodeText(candidate) === label);
|
||||||
|
assert.ok(button, `missing button: ${label}`);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a late poll response cannot issue status RPCs or schedule work after effect cleanup', async (t) => {
|
||||||
|
const clock = createBrowserClock();
|
||||||
|
t.after(() => clock.restore());
|
||||||
|
const oldPoll = deferred();
|
||||||
|
let beginCount = 0;
|
||||||
|
let statusCalls = 0;
|
||||||
|
const rpcCall = async (endpoint, payload) => {
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||||
|
statusCalls += 1;
|
||||||
|
return ok(snapshot());
|
||||||
|
}
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) {
|
||||||
|
beginCount += 1;
|
||||||
|
return ok(provisioning(beginCount === 1 ? 'attempt-old' : 'attempt-new'));
|
||||||
|
}
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.cancelProvisioning) return ok({ cancelled: true });
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.pollProvisioning) {
|
||||||
|
assert.equal(payload.attemptId, 'attempt-old');
|
||||||
|
return oldPoll.promise;
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected endpoint: ${endpoint}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer;
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(renderer, '生成钉钉二维码').props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
clock.runTimeout(1_000);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(renderer, '换一个二维码').props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(clock.timeouts.size, 1, 'the replacement attempt owns one poll timer');
|
||||||
|
assert.equal(statusCalls, 1, 'only the initial status request has run');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
oldPoll.resolve(ok(provisioning('attempt-old', {
|
||||||
|
status: 'connected',
|
||||||
|
botId: 'bot-old',
|
||||||
|
})));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(statusCalls, 1, 'the disposed poll cannot start a connected-status refresh');
|
||||||
|
assert.equal(clock.timeouts.size, 1, 'the disposed poll cannot add another timer');
|
||||||
|
assert.equal(renderer.root.findByType('img').props.src, 'data:image/png;base64,QkJCQg==');
|
||||||
|
|
||||||
|
act(() => renderer.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale periodic status response cannot restore cancelled provisioning', async (t) => {
|
||||||
|
const clock = createBrowserClock();
|
||||||
|
t.after(() => clock.restore());
|
||||||
|
const staleStatus = deferred();
|
||||||
|
let statusCalls = 0;
|
||||||
|
const rpcCall = async (endpoint) => {
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||||
|
statusCalls += 1;
|
||||||
|
return statusCalls === 1 ? ok(snapshot()) : staleStatus.promise;
|
||||||
|
}
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) {
|
||||||
|
return ok(provisioning('attempt-old'));
|
||||||
|
}
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.cancelProvisioning) return ok({ cancelled: true });
|
||||||
|
throw new Error(`unexpected endpoint: ${endpoint}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer;
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(renderer, '生成钉钉二维码').props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
const firstAnnouncement = [...clock.frames.keys()][0];
|
||||||
|
assert.ok(firstAnnouncement, 'starting provisioning schedules an announcement');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
void clock.runInterval(15_000);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(renderer, '取消').props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
assert.ok(clock.cancelledFrames.includes(firstAnnouncement), 'a new announcement cancels the old frame');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
staleStatus.resolve(ok(snapshot({ provisioning: provisioning('attempt-old') })));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(renderer.root.findAllByType('img').length, 0);
|
||||||
|
findButton(renderer, '生成钉钉二维码');
|
||||||
|
assert.ok(clock.frames.size > 0, 'cancel leaves its announcement or focus frame pending');
|
||||||
|
|
||||||
|
act(() => renderer.unmount());
|
||||||
|
assert.equal(clock.frames.size, 0, 'unmount cancels every pending animation frame');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmount does not cancel a Host provisioning task that already started', async (t) => {
|
||||||
|
const clock = createBrowserClock();
|
||||||
|
t.after(() => clock.restore());
|
||||||
|
const begin = deferred();
|
||||||
|
const calls = [];
|
||||||
|
const rpcCall = async (endpoint, payload, signal) => {
|
||||||
|
calls.push({ endpoint, payload, signal });
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.status) return ok(snapshot());
|
||||||
|
if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) return begin.promise;
|
||||||
|
throw new Error(`unexpected endpoint: ${endpoint}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer;
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(renderer, '生成钉钉二维码').props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const beginCall = calls.find((call) => call.endpoint === DINGTALK_ENDPOINTS.beginProvisioning);
|
||||||
|
assert.ok(beginCall);
|
||||||
|
assert.equal(beginCall.signal, undefined, 'component teardown must not abort Host provisioning');
|
||||||
|
act(() => renderer.unmount());
|
||||||
|
|
||||||
|
begin.resolve(ok(provisioning('attempt-old')));
|
||||||
|
await flushMicrotasks();
|
||||||
|
assert.equal(
|
||||||
|
calls.filter((call) => call.endpoint === DINGTALK_ENDPOINTS.cancelProvisioning).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(clock.frames.size, 0);
|
||||||
|
});
|
||||||
86
test/channels/dingtalk/client-ui.test.mjs
Normal file
86
test/channels/dingtalk/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const CLIENT_URL = new URL('../../../plugin-src/client/channels/dingtalk/index.js', import.meta.url);
|
||||||
|
const STYLES_URL = new URL('../../../plugin-src/client/channels/dingtalk/styles.js', import.meta.url);
|
||||||
|
|
||||||
|
test('standalone client exports a reusable settings component and registration', async () => {
|
||||||
|
const source = await readFile(CLIENT_URL, 'utf8');
|
||||||
|
assert.match(source, /export const name = 'dingtalk-settings'/);
|
||||||
|
assert.match(source, /export const inject = \['slots', 'connection'\]/);
|
||||||
|
assert.match(source, /export function DingtalkSettingsTab\(\{ rpcCall \}\)/);
|
||||||
|
assert.match(source, /export function apply\(ctx\)/);
|
||||||
|
assert.match(source, /ctx\.connection\.rpc\.call\(DINGTALK_RPC_CHANNEL/);
|
||||||
|
assert.match(source, /id: 'dingtalk'/);
|
||||||
|
assert.match(source, /label: '钉钉'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QR guidance describes the complete official DingTalk authorization flow', async () => {
|
||||||
|
const source = await readFile(CLIENT_URL, 'utf8');
|
||||||
|
assert.match(source, /使用已加入企业\/组织的钉钉账号扫描左侧二维码/);
|
||||||
|
assert.match(source, /如果钉钉提示尚未加入组织/);
|
||||||
|
assert.match(source, /在授权页点击“一键创建新机器人”/);
|
||||||
|
assert.match(source, /保持本页打开,等待机器人自动连接/);
|
||||||
|
assert.match(source, /官方授权页目前可能显示 OpenClaw 品牌/);
|
||||||
|
assert.match(source, /safeQrSource\(provision\.qrCodeDataUrl\)/);
|
||||||
|
assert.doesNotMatch(source, /verificationUrl|打开备用链接/);
|
||||||
|
assert.doesNotMatch(source, /dangerouslySetInnerHTML|window\.open\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the settings page has no local sender approval workflow', async () => {
|
||||||
|
const source = await readFile(CLIENT_URL, 'utf8');
|
||||||
|
assert.match(source, /payload: \{ botId: account\.botId, confirm: true \}/);
|
||||||
|
assert.doesNotMatch(source, /SenderAccess|approveSender|revokeSender|待批准|已批准|批准使用/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the client uses an isolated compact and accessible DingTalk style namespace', async () => {
|
||||||
|
const [source, styles] = await Promise.all([
|
||||||
|
readFile(CLIENT_URL, 'utf8'),
|
||||||
|
readFile(STYLES_URL, 'utf8'),
|
||||||
|
]);
|
||||||
|
assert.doesNotMatch(source, /\bdxw-|\bbxf-/);
|
||||||
|
assert.doesNotMatch(styles, /\bdxw-|\bbxf-/);
|
||||||
|
assert.match(styles, /\.ddt-page \{/);
|
||||||
|
assert.match(styles, /--ddt-accent: #1677ff/);
|
||||||
|
assert.match(styles, /@media \(max-width: 720px\)/);
|
||||||
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)/);
|
||||||
|
assert.match(source, /aria-live': 'polite'/);
|
||||||
|
assert.match(source, /role: 'alertdialog'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the QR card responds to its plugin panel width instead of the browser viewport', async () => {
|
||||||
|
const styles = await readFile(STYLES_URL, 'utf8');
|
||||||
|
assert.match(styles, /container-type: inline-size/);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/@container \(max-width: 680px\)[\s\S]*\.ddt-qrLayout \{ grid-template-columns: minmax\(0, 1fr\); justify-items: center;/,
|
||||||
|
);
|
||||||
|
assert.match(styles, /\.ddt-qrFrame \{[^\n]*width: min\(270px, 100%\)/);
|
||||||
|
assert.match(styles, /\.ddt-countdown \{ width: min\(270px, 100%\)/);
|
||||||
|
assert.match(styles, /\.ddt-qrLayout \{[^\n]*align-items: start;/);
|
||||||
|
assert.match(styles, /\.ddt-qrColumn \{ width: 100%; min-width: 0; \}/);
|
||||||
|
assert.match(styles, /\.ddt-qrCopy \{ min-width: 0; overflow-wrap: anywhere; \}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bot metrics stay in one compact three-column row at narrow widths', async () => {
|
||||||
|
const styles = await readFile(STYLES_URL, 'utf8');
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.ddt-metrics \{[^\n]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
styles,
|
||||||
|
/\.ddt-metrics \{ grid-template-columns: minmax\(0, 1fr\); \}/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the narrow-panel toolbar keeps all three controls on one row', async () => {
|
||||||
|
const styles = await readFile(STYLES_URL, 'utf8');
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/@container \(max-width: 680px\)[\s\S]*\.ddt-tools \{ width: 100%; flex-wrap: nowrap; gap: 6px; \}/,
|
||||||
|
);
|
||||||
|
assert.match(styles, /\.ddt-tools \.ddt-badge \{ min-height: 34px;/);
|
||||||
|
assert.match(styles, /\.ddt-tools \.ddt-button \{[^\n]*white-space: nowrap;/);
|
||||||
|
});
|
||||||
127
test/channels/dingtalk/config-store.test.mjs
Normal file
127
test/channels/dingtalk/config-store.test.mjs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DingtalkConfigStore,
|
||||||
|
deriveDingtalkBotIdentity,
|
||||||
|
deriveDingtalkSenderKey,
|
||||||
|
} from '../../../src/channels/dingtalk/config-store.mjs';
|
||||||
|
|
||||||
|
async function temporaryConfig(t) {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'dsh-dingtalk-config-'));
|
||||||
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
return join(directory, 'nested', 'bots.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bot identity is deterministic while sender keys are random opaque values', () => {
|
||||||
|
const clientId = 'ding-client-one';
|
||||||
|
const clientDigest = createHash('sha256').update(clientId).digest('hex').slice(0, 24);
|
||||||
|
assert.deepEqual(deriveDingtalkBotIdentity(clientId), {
|
||||||
|
botId: `dt_${clientDigest}`,
|
||||||
|
secretRef: `DSH_DINGTALK_BOT_SECRET_${clientDigest.toUpperCase()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstSenderKey = deriveDingtalkSenderKey();
|
||||||
|
const secondSenderKey = deriveDingtalkSenderKey();
|
||||||
|
assert.match(firstSenderKey, /^dt_sender_[a-f0-9]{32}$/);
|
||||||
|
assert.notEqual(firstSenderKey, secondSenderKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config persists only clientId, derived secretRef, and approvedSenders with mode 0600', async (t) => {
|
||||||
|
const path = await temporaryConfig(t);
|
||||||
|
const store = await new DingtalkConfigStore(path).load();
|
||||||
|
const identity = deriveDingtalkBotIdentity('ding-client-one');
|
||||||
|
await store.save({
|
||||||
|
...identity,
|
||||||
|
clientId: 'ding-client-one',
|
||||||
|
approvedSenders: [{
|
||||||
|
senderKey: 'dt_sender_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
staffId: 'staff-private-one',
|
||||||
|
displayName: '测试用户',
|
||||||
|
approvedAt: '2026-08-15T00:00:00.000Z',
|
||||||
|
}],
|
||||||
|
ignoredMetadata: 'not-persisted',
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = JSON.parse(await readFile(path, 'utf8'));
|
||||||
|
assert.deepEqual(Object.keys(document.bots[0]).sort(), [
|
||||||
|
'approvedSenders',
|
||||||
|
'clientId',
|
||||||
|
'secretRef',
|
||||||
|
]);
|
||||||
|
assert.equal('botId' in document.bots[0], false);
|
||||||
|
assert.equal('clientSecret' in document.bots[0], false);
|
||||||
|
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||||
|
|
||||||
|
const reloaded = await new DingtalkConfigStore(path).load();
|
||||||
|
assert.deepEqual(reloaded.get(identity.botId), {
|
||||||
|
botId: identity.botId,
|
||||||
|
clientId: 'ding-client-one',
|
||||||
|
secretRef: identity.secretRef,
|
||||||
|
approvedSenders: [{
|
||||||
|
senderKey: 'dt_sender_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
staffId: 'staff-private-one',
|
||||||
|
displayName: '测试用户',
|
||||||
|
approvedAt: '2026-08-15T00:00:00.000Z',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config rejects a persisted secret or a non-derived secret reference', async (t) => {
|
||||||
|
const path = await temporaryConfig(t);
|
||||||
|
const identity = deriveDingtalkBotIdentity('ding-client-one');
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
bots: [{
|
||||||
|
clientId: 'ding-client-one',
|
||||||
|
secretRef: identity.secretRef,
|
||||||
|
clientSecret: 'must-not-be-here',
|
||||||
|
approvedSenders: [],
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
await assert.rejects(
|
||||||
|
new DingtalkConfigStore(path).load(),
|
||||||
|
/invalid bot data/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = await new DingtalkConfigStore(`${path}.other`).load();
|
||||||
|
await assert.rejects(
|
||||||
|
store.save({
|
||||||
|
...identity,
|
||||||
|
clientId: 'ding-client-one',
|
||||||
|
clientSecret: 'must-not-be-here',
|
||||||
|
approvedSenders: [],
|
||||||
|
}),
|
||||||
|
/invalid dsh-dingtalk bot data/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
store.save({
|
||||||
|
botId: identity.botId,
|
||||||
|
clientId: 'ding-client-one',
|
||||||
|
secretRef: 'DSH_DINGTALK_BOT_SECRET_000000000000000000000000',
|
||||||
|
approvedSenders: [],
|
||||||
|
}),
|
||||||
|
/invalid dsh-dingtalk bot data/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queued concurrent saves retain every bot and remove by derived bot ID', async (t) => {
|
||||||
|
const path = await temporaryConfig(t);
|
||||||
|
const store = await new DingtalkConfigStore(path).load();
|
||||||
|
const first = deriveDingtalkBotIdentity('ding-client-one');
|
||||||
|
const second = deriveDingtalkBotIdentity('ding-client-two');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
store.save({ ...first, clientId: 'ding-client-one', approvedSenders: [] }),
|
||||||
|
store.save({ ...second, clientId: 'ding-client-two', approvedSenders: [] }),
|
||||||
|
]);
|
||||||
|
assert.equal(store.list().length, 2);
|
||||||
|
assert.equal(store.getByClientId('ding-client-two').botId, second.botId);
|
||||||
|
assert.equal((await store.remove(first.botId)).clientId, 'ding-client-one');
|
||||||
|
assert.deepEqual(store.list().map((bot) => bot.botId), [second.botId]);
|
||||||
|
});
|
||||||
73
test/channels/dingtalk/connection-supervisor.test.mjs
Normal file
73
test/channels/dingtalk/connection-supervisor.test.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { ConnectionSupervisor } from '../../../plugin-src/host/channels/dingtalk/connection-supervisor.mjs';
|
||||||
|
|
||||||
|
function scheduler() {
|
||||||
|
const tasks = [];
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
setTimeout(callback, delay) {
|
||||||
|
const task = { callback, delay, unref() {} };
|
||||||
|
tasks.push(task);
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
clearTimeout(task) {
|
||||||
|
const index = tasks.indexOf(task);
|
||||||
|
if (index >= 0) tasks.splice(index, 1);
|
||||||
|
},
|
||||||
|
async runNext() {
|
||||||
|
const task = tasks.shift();
|
||||||
|
assert.ok(task);
|
||||||
|
task.callback();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('supervisor starts configured DingTalk runtimes after Harness is healthy', async () => {
|
||||||
|
const timers = scheduler();
|
||||||
|
let healthChecks = 0;
|
||||||
|
let initializes = 0;
|
||||||
|
const supervisor = new ConnectionSupervisor({
|
||||||
|
harness: { async ensureRunning() { healthChecks += 1; } },
|
||||||
|
controller: {
|
||||||
|
async initialize() {
|
||||||
|
initializes += 1;
|
||||||
|
return { totals: { configured: 1, connected: 1 } };
|
||||||
|
},
|
||||||
|
status() {},
|
||||||
|
},
|
||||||
|
setTimeoutImpl: timers.setTimeout,
|
||||||
|
clearTimeoutImpl: timers.clearTimeout,
|
||||||
|
healthyIntervalMs: 9_000,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
await timers.runNext();
|
||||||
|
assert.equal(healthChecks, 1);
|
||||||
|
assert.equal(initializes, 1);
|
||||||
|
assert.equal((await supervisor.ready).totals.connected, 1);
|
||||||
|
assert.equal(timers.tasks[0].delay, 9_000);
|
||||||
|
await supervisor.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supervisor retries an offline DingTalk runtime without blocking startup', async () => {
|
||||||
|
const timers = scheduler();
|
||||||
|
const warnings = [];
|
||||||
|
const supervisor = new ConnectionSupervisor({
|
||||||
|
harness: { async ensureRunning() {} },
|
||||||
|
controller: {
|
||||||
|
async initialize() { return { totals: { configured: 2, connected: 1 } }; },
|
||||||
|
status() {},
|
||||||
|
},
|
||||||
|
logger: { warn: (...args) => warnings.push(args) },
|
||||||
|
retryDelaysMs: [7, 11],
|
||||||
|
setTimeoutImpl: timers.setTimeout,
|
||||||
|
clearTimeoutImpl: timers.clearTimeout,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
await timers.runNext();
|
||||||
|
assert.equal(timers.tasks[0].delay, 7);
|
||||||
|
assert.match(warnings[0][0], /1\/2 bots connected/);
|
||||||
|
await supervisor.close();
|
||||||
|
});
|
||||||
120
test/channels/dingtalk/device-auth.test.mjs
Normal file
120
test/channels/dingtalk/device-auth.test.mjs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DingtalkDeviceAuth,
|
||||||
|
DingtalkDeviceAuthError,
|
||||||
|
} from '../../../src/channels/dingtalk/device-auth.mjs';
|
||||||
|
|
||||||
|
function fetchFixture(responses) {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
fetch: async (url, init) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => responses.shift(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('device auth performs init then begin with the fixed DingTalk registration source', async () => {
|
||||||
|
const fixture = fetchFixture([
|
||||||
|
{ errcode: 0, nonce: 'nonce-private' },
|
||||||
|
{
|
||||||
|
errcode: 0,
|
||||||
|
device_code: 'device-private',
|
||||||
|
user_code: 'user-code',
|
||||||
|
verification_uri: 'https://oapi.dingtalk.com/verify',
|
||||||
|
verification_uri_complete: 'https://oapi.dingtalk.com/verify?user_code=user-code',
|
||||||
|
expires_in: 120,
|
||||||
|
interval: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const auth = new DingtalkDeviceAuth({ fetch: fixture.fetch, clock: () => 10_000 });
|
||||||
|
|
||||||
|
const result = await auth.start();
|
||||||
|
|
||||||
|
assert.deepEqual(fixture.calls.map((call) => call.url), [
|
||||||
|
'https://oapi.dingtalk.com/app/registration/init',
|
||||||
|
'https://oapi.dingtalk.com/app/registration/begin',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(JSON.parse(fixture.calls[0].init.body), { source: 'DING_DWS_CLAW' });
|
||||||
|
assert.deepEqual(JSON.parse(fixture.calls[1].init.body), { nonce: 'nonce-private' });
|
||||||
|
assert.equal(fixture.calls[0].init.redirect, 'error');
|
||||||
|
assert.equal(result.deviceCode, 'device-private');
|
||||||
|
assert.equal(result.verificationUrl, 'https://oapi.dingtalk.com/verify?user_code=user-code');
|
||||||
|
assert.equal(result.expiresAt, 130_000);
|
||||||
|
assert.equal(result.pollIntervalMs, 3_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('device auth polls with a host-only device code and normalizes credentials', async () => {
|
||||||
|
const fixture = fetchFixture([{
|
||||||
|
errcode: 0,
|
||||||
|
status: 'success',
|
||||||
|
client_id: 'ding-client',
|
||||||
|
client_secret: 'private-secret',
|
||||||
|
}]);
|
||||||
|
const auth = new DingtalkDeviceAuth({ fetch: fixture.fetch });
|
||||||
|
|
||||||
|
const result = await auth.poll({ deviceCode: 'device-private' });
|
||||||
|
|
||||||
|
assert.equal(fixture.calls[0].url, 'https://oapi.dingtalk.com/app/registration/poll');
|
||||||
|
assert.deepEqual(JSON.parse(fixture.calls[0].init.body), { device_code: 'device-private' });
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
status: 'SUCCESS',
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'private-secret',
|
||||||
|
failReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('device auth accepts only default-port HTTPS DingTalk registration hosts', () => {
|
||||||
|
const fetch = async () => assert.fail('fetch must not run');
|
||||||
|
for (const baseUrl of [
|
||||||
|
'http://oapi.dingtalk.com',
|
||||||
|
'https://oapi.dingtalk.com:8443',
|
||||||
|
'https://example.com',
|
||||||
|
'https://dingtalk.com@example.com',
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() => new DingtalkDeviceAuth({ fetch, baseUrl }),
|
||||||
|
/valid HTTPS URL/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.doesNotThrow(
|
||||||
|
() => new DingtalkDeviceAuth({ fetch, baseUrl: 'https://staging.dingtalk.com/root/' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('device auth API failures do not echo remote response details', async () => {
|
||||||
|
const fixture = fetchFixture([{
|
||||||
|
errcode: 400,
|
||||||
|
errmsg: 'do not leak device-private or client-secret-private',
|
||||||
|
}]);
|
||||||
|
const auth = new DingtalkDeviceAuth({ fetch: fixture.fetch });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
auth.start(),
|
||||||
|
(error) => {
|
||||||
|
assert.ok(error instanceof DingtalkDeviceAuthError);
|
||||||
|
assert.equal(error.code, 'api-error');
|
||||||
|
assert.doesNotMatch(error.message, /device-private|client-secret-private/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('device auth bounds a stalled registration request with a local timeout', async () => {
|
||||||
|
const fetch = async (_url, { signal }) => new Promise((_resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||||
|
});
|
||||||
|
const auth = new DingtalkDeviceAuth({ fetch, timeoutMs: 5 });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
auth.start(),
|
||||||
|
(error) => error instanceof DingtalkDeviceAuthError && error.code === 'timeout',
|
||||||
|
);
|
||||||
|
});
|
||||||
334
test/channels/dingtalk/dingtalk-api.test.mjs
Normal file
334
test/channels/dingtalk/dingtalk-api.test.mjs
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDingtalkApi,
|
||||||
|
DINGTALK_AI_CARD_TEMPLATE_ID,
|
||||||
|
DINGTALK_API_BASE_URL,
|
||||||
|
normalizeDingtalkCardMarkdown,
|
||||||
|
normalizeDingtalkSessionWebhook,
|
||||||
|
splitDingtalkText,
|
||||||
|
} from '../../../src/channels/dingtalk/dingtalk-api.mjs';
|
||||||
|
|
||||||
|
function jsonResponse(value, { status = 200 } = {}) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise((resolvePromise) => { resolve = resolvePromise; });
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('registration API performs init, begin, and poll with normalized results', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const fetchImpl = async (url, options) => {
|
||||||
|
calls.push({ url: url.toString(), options });
|
||||||
|
if (url.pathname.endsWith('/init')) return jsonResponse({ errcode: 0, nonce: ' nonce-1 ' });
|
||||||
|
if (url.pathname.endsWith('/begin')) {
|
||||||
|
return jsonResponse({
|
||||||
|
errcode: 0,
|
||||||
|
device_code: ' device-1 ',
|
||||||
|
user_code: 'user-1',
|
||||||
|
verification_uri: 'https://h5.dingtalk.com/verify',
|
||||||
|
verification_uri_complete: 'https://h5.dingtalk.com/verify?code=one',
|
||||||
|
expires_in: 300,
|
||||||
|
interval: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResponse({
|
||||||
|
errcode: 0,
|
||||||
|
status: 'success',
|
||||||
|
client_id: 'ding-client',
|
||||||
|
client_secret: 'host-only-secret',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const api = createDingtalkApi({ fetchImpl, cardMinIntervalMs: 0, cardBackoffMs: 0 });
|
||||||
|
|
||||||
|
const begun = await api.beginRegistration();
|
||||||
|
const polled = await api.pollRegistration({ deviceCode: begun.deviceCode });
|
||||||
|
|
||||||
|
assert.deepEqual(begun, {
|
||||||
|
deviceCode: 'device-1',
|
||||||
|
userCode: 'user-1',
|
||||||
|
verificationUri: 'https://h5.dingtalk.com/verify',
|
||||||
|
verificationUriComplete: 'https://h5.dingtalk.com/verify?code=one',
|
||||||
|
expiresInSeconds: 300,
|
||||||
|
intervalSeconds: 3,
|
||||||
|
});
|
||||||
|
assert.deepEqual(polled, {
|
||||||
|
status: 'SUCCESS',
|
||||||
|
failReason: undefined,
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-only-secret',
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls.map(({ url, options }) => ({
|
||||||
|
path: new URL(url).pathname,
|
||||||
|
body: JSON.parse(options.body),
|
||||||
|
redirect: options.redirect,
|
||||||
|
})), [
|
||||||
|
{ path: '/app/registration/init', body: { source: 'DING_DWS_CLAW' }, redirect: 'error' },
|
||||||
|
{ path: '/app/registration/begin', body: { nonce: 'nonce-1' }, redirect: 'error' },
|
||||||
|
{ path: '/app/registration/poll', body: { device_code: 'device-1' }, redirect: 'error' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session replies use the fixed token endpoint, reject redirects, and cache access tokens', async () => {
|
||||||
|
const calls = [];
|
||||||
|
let now = 1_000;
|
||||||
|
const fetchImpl = async (url, options) => {
|
||||||
|
calls.push({ url: url.toString(), options });
|
||||||
|
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||||
|
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
|
||||||
|
}
|
||||||
|
return jsonResponse({ errcode: 0 });
|
||||||
|
};
|
||||||
|
const api = createDingtalkApi({ fetchImpl, now: () => now });
|
||||||
|
const request = {
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-only-secret',
|
||||||
|
sessionWebhook: 'https://oapi.dingtalk.com/robot/sendBySession?session=opaque',
|
||||||
|
text: '回答',
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.sendText(request);
|
||||||
|
now += 10_000;
|
||||||
|
await api.sendText({ ...request, text: '继续' });
|
||||||
|
|
||||||
|
assert.equal(calls.filter(({ url }) => url.includes('/oauth2/accessToken')).length, 1);
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
for (const { options } of calls) assert.equal(options.redirect, 'error');
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
||||||
|
appKey: 'ding-client',
|
||||||
|
appSecret: 'host-only-secret',
|
||||||
|
});
|
||||||
|
assert.equal(calls[1].options.headers['x-acs-dingtalk-access-token'], 'access-token');
|
||||||
|
assert.deepEqual(JSON.parse(calls[2].options.body), {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: { content: '继续' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session webhook validation accepts only HTTPS DingTalk hosts on the default port', () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeDingtalkSessionWebhook('https://dingtalk.com/reply?ticket=one'),
|
||||||
|
'https://dingtalk.com/reply?ticket=one',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
normalizeDingtalkSessionWebhook('https://oapi.dingtalk.com:443/reply?ticket=one'),
|
||||||
|
'https://oapi.dingtalk.com/reply?ticket=one',
|
||||||
|
);
|
||||||
|
for (const value of [
|
||||||
|
'http://oapi.dingtalk.com/reply',
|
||||||
|
'https://oapi.dingtalk.com.evil.example/reply',
|
||||||
|
'https://user@oapi.dingtalk.com/reply',
|
||||||
|
'https://oapi.dingtalk.com:8443/reply',
|
||||||
|
]) {
|
||||||
|
assert.throws(() => normalizeDingtalkSessionWebhook(value), /不受信任/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Card replies create, deliver, stream full snapshots, and finalize on fixed endpoints', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const fetchImpl = async (url, options) => {
|
||||||
|
calls.push({ url: url.toString(), options });
|
||||||
|
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||||
|
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
const api = createDingtalkApi({ fetchImpl, cardMinIntervalMs: 0, cardBackoffMs: 0 });
|
||||||
|
const credentials = { clientId: 'ding-client', clientSecret: 'host-only-secret' };
|
||||||
|
const card = await api.createAiCard({
|
||||||
|
...credentials,
|
||||||
|
target: { type: 'user', userId: 'staff-one' },
|
||||||
|
initialText: '正在处理…',
|
||||||
|
});
|
||||||
|
await api.updateAiCard({ ...credentials, ...card, text: '第一行\n第二行' });
|
||||||
|
await api.finishAiCard({ ...credentials, ...card, text: '最终回答' });
|
||||||
|
|
||||||
|
const cardCalls = calls.slice(1).map(({ url, options }) => ({
|
||||||
|
method: options.method,
|
||||||
|
path: new URL(url).pathname,
|
||||||
|
body: JSON.parse(options.body),
|
||||||
|
token: options.headers['x-acs-dingtalk-access-token'],
|
||||||
|
redirect: options.redirect,
|
||||||
|
}));
|
||||||
|
assert.deepEqual(cardCalls.map(({ method, path }) => ({ method, path })), [
|
||||||
|
{ method: 'POST', path: '/v1.0/card/instances' },
|
||||||
|
{ method: 'POST', path: '/v1.0/card/instances/deliver' },
|
||||||
|
{ method: 'PUT', path: '/v1.0/card/instances' },
|
||||||
|
{ method: 'PUT', path: '/v1.0/card/streaming' },
|
||||||
|
{ method: 'PUT', path: '/v1.0/card/streaming' },
|
||||||
|
{ method: 'PUT', path: '/v1.0/card/streaming' },
|
||||||
|
{ method: 'PUT', path: '/v1.0/card/instances' },
|
||||||
|
]);
|
||||||
|
assert.ok(cardCalls.every(({ token, redirect }) => token === 'access-token' && redirect === 'error'));
|
||||||
|
assert.equal(cardCalls[0].body.cardTemplateId, DINGTALK_AI_CARD_TEMPLATE_ID);
|
||||||
|
assert.equal(cardCalls[0].body.outTrackId, card.cardInstanceId);
|
||||||
|
assert.equal(cardCalls[1].body.openSpaceId, 'dtv1.card//IM_ROBOT.staff-one');
|
||||||
|
assert.equal(cardCalls[1].body.imRobotOpenDeliverModel.robotCode, 'ding-client');
|
||||||
|
assert.equal(cardCalls[2].body.cardData.cardParamMap.flowStatus, '2');
|
||||||
|
assert.equal(cardCalls[3].body.content, '正在处理…');
|
||||||
|
assert.equal(cardCalls[4].body.content, '第一行<br>第二行');
|
||||||
|
assert.equal(cardCalls[4].body.isFull, true);
|
||||||
|
assert.equal(cardCalls[4].body.isFinalize, false);
|
||||||
|
assert.equal(cardCalls[5].body.content, '最终回答');
|
||||||
|
assert.equal(cardCalls[5].body.isFinalize, true);
|
||||||
|
assert.equal(cardCalls[6].body.cardData.cardParamMap.flowStatus, '3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Card markdown preserves fenced code while rendering ordinary line breaks', () => {
|
||||||
|
assert.equal(normalizeDingtalkCardMarkdown('一\n二'), '一<br>二');
|
||||||
|
assert.equal(
|
||||||
|
normalizeDingtalkCardMarkdown('```js\nconst answer = 42\n```\n完成'),
|
||||||
|
'```js\nconst answer = 42\n```\n完成',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Card start slots preserve 20 QPS without blocking cleanup behind slow HTTP', async () => {
|
||||||
|
const firstUpdate = deferred();
|
||||||
|
const bodies = [];
|
||||||
|
const waits = [];
|
||||||
|
let now = 0;
|
||||||
|
const fetchImpl = async (url, options) => {
|
||||||
|
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||||
|
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
|
||||||
|
}
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
bodies.push(body);
|
||||||
|
if (new URL(url).pathname === '/v1.0/card/streaming' && body.isError === false) {
|
||||||
|
await firstUpdate.promise;
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
const api = createDingtalkApi({
|
||||||
|
fetchImpl,
|
||||||
|
now: () => now,
|
||||||
|
cardMinIntervalMs: 50,
|
||||||
|
delay: async (ms) => {
|
||||||
|
waits.push(ms);
|
||||||
|
now += ms;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const request = {
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-only-secret',
|
||||||
|
cardInstanceId: 'card-one',
|
||||||
|
};
|
||||||
|
|
||||||
|
const slowUpdate = api.updateAiCard({ ...request, text: '仍在请求中' });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await api.failAiCard({ ...request, text: '及时收口' });
|
||||||
|
|
||||||
|
assert.equal(bodies.some((body) => body.isError === true), true);
|
||||||
|
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
|
||||||
|
assert.deepEqual(waits, [50, 50]);
|
||||||
|
firstUpdate.resolve();
|
||||||
|
await slowUpdate;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Card retries one QPS rejection after the configured backoff', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const waits = [];
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||||
|
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
|
||||||
|
}
|
||||||
|
attempts += 1;
|
||||||
|
return attempts === 1 ? jsonResponse({}, { status: 403 }) : jsonResponse({});
|
||||||
|
};
|
||||||
|
const api = createDingtalkApi({
|
||||||
|
fetchImpl,
|
||||||
|
cardMinIntervalMs: 0,
|
||||||
|
cardBackoffMs: 1_000,
|
||||||
|
delay: async (ms) => waits.push(ms),
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.updateAiCard({
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-only-secret',
|
||||||
|
cardInstanceId: 'card-one',
|
||||||
|
text: '重试内容',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(attempts, 2);
|
||||||
|
assert.deepEqual(waits, [1_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Card cleanup marks failure while a completed final frame never falls back twice', async () => {
|
||||||
|
const calls = [];
|
||||||
|
let rejectCompletedStatus = true;
|
||||||
|
const fetchImpl = async (url, options) => {
|
||||||
|
calls.push({ url: url.toString(), options });
|
||||||
|
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||||
|
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
|
||||||
|
}
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
if (rejectCompletedStatus
|
||||||
|
&& new URL(url).pathname === '/v1.0/card/instances'
|
||||||
|
&& body.cardData?.cardParamMap?.flowStatus === '3') {
|
||||||
|
return jsonResponse({}, { status: 500 });
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
const api = createDingtalkApi({ fetchImpl, cardMinIntervalMs: 0, cardBackoffMs: 0 });
|
||||||
|
const request = {
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-only-secret',
|
||||||
|
cardInstanceId: 'card-one',
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(await api.finishAiCard({ ...request, text: '最终答案' }), {
|
||||||
|
delivered: true,
|
||||||
|
completed: false,
|
||||||
|
});
|
||||||
|
rejectCompletedStatus = false;
|
||||||
|
await api.failAiCard({ ...request, text: '处理失败' });
|
||||||
|
|
||||||
|
const bodies = calls
|
||||||
|
.filter(({ url }) => new URL(url).pathname.startsWith('/v1.0/card/'))
|
||||||
|
.map(({ options }) => JSON.parse(options.body));
|
||||||
|
assert.equal(bodies.some((body) => body.isFinalize === true && body.isError === false), true);
|
||||||
|
assert.equal(bodies.some((body) => body.isError === true), true);
|
||||||
|
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Card creation closes a delivered card with an independent signal after abort', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const bodies = [];
|
||||||
|
const fetchImpl = async (url, options) => {
|
||||||
|
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
|
||||||
|
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
|
||||||
|
}
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
bodies.push(body);
|
||||||
|
if (options.method === 'PUT' && body.cardData?.cardParamMap?.flowStatus === '2') {
|
||||||
|
controller.abort(new DOMException('stopped', 'AbortError'));
|
||||||
|
throw controller.signal.reason;
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
};
|
||||||
|
const api = createDingtalkApi({ fetchImpl, cardMinIntervalMs: 0, cardBackoffMs: 0 });
|
||||||
|
|
||||||
|
await assert.rejects(api.createAiCard({
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-only-secret',
|
||||||
|
target: { type: 'user', userId: 'staff-one' },
|
||||||
|
initialText: '正在处理',
|
||||||
|
signal: controller.signal,
|
||||||
|
}), { name: 'AbortError' });
|
||||||
|
|
||||||
|
assert.equal(bodies.some((body) => body.isError === true), true);
|
||||||
|
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('text splitting prefers line boundaries and never produces oversized chunks', () => {
|
||||||
|
const chunks = splitDingtalkText('第一段\n第二段很长\n第三段', 8);
|
||||||
|
assert.equal(chunks.join('').replaceAll('\n', ''), '第一段第二段很长第三段');
|
||||||
|
assert.ok(chunks.every((chunk) => chunk.length <= 8));
|
||||||
|
});
|
||||||
271
test/channels/dingtalk/dingtalk-bridge.test.mjs
Normal file
271
test/channels/dingtalk/dingtalk-bridge.test.mjs
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDingtalkBridgeStatus,
|
||||||
|
DingtalkHarnessBridge,
|
||||||
|
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||||
|
|
||||||
|
function message(id, text, overrides = {}) {
|
||||||
|
return {
|
||||||
|
msgId: id,
|
||||||
|
msgtype: 'text',
|
||||||
|
text: { content: text },
|
||||||
|
conversationType: '1',
|
||||||
|
conversationId: `conversation-${id}`,
|
||||||
|
senderStaffId: 'staff-approved',
|
||||||
|
senderNick: '钉钉用户',
|
||||||
|
sessionWebhook: `https://oapi.dingtalk.com/robot/reply?ticket=${id}`,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateFixture() {
|
||||||
|
const sessions = new Map();
|
||||||
|
const seen = new Set();
|
||||||
|
const pending = new Map();
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
seen,
|
||||||
|
pending,
|
||||||
|
state: {
|
||||||
|
hasSeen: (id) => seen.has(id),
|
||||||
|
markSeen: async (id) => seen.add(id),
|
||||||
|
sessionFor: (key) => sessions.get(key) ?? null,
|
||||||
|
setSession: async (key, sessionId) => sessions.set(key, sessionId),
|
||||||
|
clearSession: async (key) => sessions.delete(key),
|
||||||
|
pendingSenders: () => [...pending.values()].map((entry) => structuredClone(entry)),
|
||||||
|
recordPendingSender: async ({ staffId, displayName, lastSeenAt }) => {
|
||||||
|
const existing = [...pending.values()].find((entry) => entry.staffId === staffId);
|
||||||
|
const entry = {
|
||||||
|
requestId: existing?.requestId ?? `request-${staffId}`,
|
||||||
|
staffId,
|
||||||
|
displayName,
|
||||||
|
requestedAt: existing?.requestedAt ?? lastSeenAt,
|
||||||
|
lastSeenAt,
|
||||||
|
};
|
||||||
|
pending.set(entry.requestId, entry);
|
||||||
|
return structuredClone(entry);
|
||||||
|
},
|
||||||
|
removePendingSenderByStaffId: async (staffId) => {
|
||||||
|
const entry = [...pending.values()].find((value) => value.staffId === staffId);
|
||||||
|
if (!entry) return false;
|
||||||
|
pending.delete(entry.requestId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bridge maps a DingTalk direct conversation to one persistent Harness session', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const sent = [];
|
||||||
|
const asked = [];
|
||||||
|
const status = createDingtalkBridgeStatus();
|
||||||
|
const bridge = new DingtalkHarnessBridge({
|
||||||
|
api: { sendText: async (request) => sent.push(request) },
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async (sessionId) => sessionId === 'session-one',
|
||||||
|
createSession: async () => 'session-one',
|
||||||
|
ask: async (sessionId, text) => {
|
||||||
|
asked.push({ sessionId, text });
|
||||||
|
return 'Harness 回答';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
bridge.accept(message('one', '你好')),
|
||||||
|
bridge.accept(message('one', '重复消息')),
|
||||||
|
]);
|
||||||
|
await bridge.accept(message('two', '继续'));
|
||||||
|
|
||||||
|
assert.equal(fixture.sessions.get('p2p:staff-approved'), 'session-one');
|
||||||
|
assert.deepEqual(asked, [
|
||||||
|
{ sessionId: 'session-one', text: '你好' },
|
||||||
|
{ sessionId: 'session-one', text: '继续' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(sent.map(({ text, sessionWebhook }) => ({ text, sessionWebhook })), [
|
||||||
|
{ text: 'Harness 回答', sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=one' },
|
||||||
|
{ text: 'Harness 回答', sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=two' },
|
||||||
|
]);
|
||||||
|
assert.equal(status.messagesReceived, 2);
|
||||||
|
assert.equal(status.messagesReplied, 2);
|
||||||
|
assert.equal(status.stats.messagesReplied, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('senders in the bot visibility scope enter Harness without local approval', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const sent = [];
|
||||||
|
const asked = [];
|
||||||
|
const status = createDingtalkBridgeStatus();
|
||||||
|
const bridge = new DingtalkHarnessBridge({
|
||||||
|
api: { sendText: async (request) => sent.push(request) },
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => false,
|
||||||
|
createSession: async () => 'session-visible-sender',
|
||||||
|
ask: async (sessionId, text) => {
|
||||||
|
asked.push({ sessionId, text });
|
||||||
|
return '直接回答';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('visible', '可见范围内的问题', {
|
||||||
|
senderStaffId: 'raw-staff-id',
|
||||||
|
senderNick: '可见范围用户',
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.deepEqual(asked, [{
|
||||||
|
sessionId: 'session-visible-sender',
|
||||||
|
text: '可见范围内的问题',
|
||||||
|
}]);
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.equal(sent[0].text, '直接回答');
|
||||||
|
assert.equal(fixture.pending.size, 0);
|
||||||
|
assert.deepEqual(status.pendingSenders, []);
|
||||||
|
assert.equal(status.messagesRejected, 0);
|
||||||
|
assert.equal(status.messagesReplied, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('group messages require an explicit bot mention before Harness work', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const sent = [];
|
||||||
|
const asked = [];
|
||||||
|
const bridge = new DingtalkHarnessBridge({
|
||||||
|
api: { sendText: async (request) => sent.push(request) },
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
createSession: async () => 'session-group',
|
||||||
|
ask: async (_sessionId, text) => {
|
||||||
|
asked.push(text);
|
||||||
|
return '群聊回答';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
});
|
||||||
|
const group = {
|
||||||
|
conversationType: '2',
|
||||||
|
conversationId: 'group-one',
|
||||||
|
};
|
||||||
|
|
||||||
|
await bridge.accept(message('not-mentioned', '群聊噪音', { ...group, isInAtList: false }));
|
||||||
|
await bridge.accept(message('mentioned', '明确问题', { ...group, isInAtList: true }));
|
||||||
|
|
||||||
|
assert.deepEqual(asked, ['明确问题']);
|
||||||
|
assert.deepEqual(sent.map(({ text }) => text), ['群聊回答']);
|
||||||
|
assert.equal(bridge.status.messagesIgnored, 1);
|
||||||
|
assert.equal(fixture.sessions.get('group:group-one'), 'session-group');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bridge streams Harness snapshots into one DingTalk AI Card and finalizes it', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const calls = { create: [], update: [], finish: [], text: [] };
|
||||||
|
const bridge = new DingtalkHarnessBridge({
|
||||||
|
api: {
|
||||||
|
sendText: async (request) => calls.text.push(request),
|
||||||
|
createAiCard: async (request) => {
|
||||||
|
calls.create.push(request);
|
||||||
|
return { cardInstanceId: 'card-one' };
|
||||||
|
},
|
||||||
|
updateAiCard: async (request) => calls.update.push(request),
|
||||||
|
finishAiCard: async (request) => {
|
||||||
|
calls.finish.push(request);
|
||||||
|
return { delivered: true, completed: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => false,
|
||||||
|
createSession: async () => 'session-stream',
|
||||||
|
ask: async (_sessionId, _text, options) => {
|
||||||
|
options.onUpdate({ type: 'text', text: '生成中的完整快照' });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 510));
|
||||||
|
return '最终完整回答';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('stream', '请流式回答'));
|
||||||
|
|
||||||
|
assert.equal(calls.create.length, 1);
|
||||||
|
assert.deepEqual(calls.create[0].target, { type: 'user', userId: 'staff-approved' });
|
||||||
|
assert.equal(calls.update.at(-1).text, '生成中的完整快照');
|
||||||
|
assert.equal(calls.finish.length, 1);
|
||||||
|
assert.equal(calls.finish[0].text, '最终完整回答');
|
||||||
|
assert.equal(calls.text.length, 0);
|
||||||
|
assert.equal(bridge.status.messagesReplied, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bridge asks Harness once and falls back to final text when AI Card creation fails', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const sent = [];
|
||||||
|
let asks = 0;
|
||||||
|
const bridge = new DingtalkHarnessBridge({
|
||||||
|
api: {
|
||||||
|
sendText: async (request) => sent.push(request.text),
|
||||||
|
createAiCard: async () => { throw new Error('card unavailable'); },
|
||||||
|
updateAiCard: async () => undefined,
|
||||||
|
finishAiCard: async () => undefined,
|
||||||
|
},
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => false,
|
||||||
|
createSession: async () => 'session-fallback',
|
||||||
|
ask: async () => {
|
||||||
|
asks += 1;
|
||||||
|
return '文本降级回答';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
logger: { error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('fallback', '卡片失败也要回答'));
|
||||||
|
|
||||||
|
assert.equal(asks, 1);
|
||||||
|
assert.deepEqual(sent, ['文本降级回答']);
|
||||||
|
assert.equal(bridge.status.messagesReplied, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('commands stay local and unsafe session webhooks are rejected before Harness', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
fixture.sessions.set('p2p:staff-approved', 'old-session');
|
||||||
|
const sent = [];
|
||||||
|
let asked = 0;
|
||||||
|
const bridge = new DingtalkHarnessBridge({
|
||||||
|
api: { sendText: async (request) => sent.push(request.text) },
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
ensureRunning: async () => true,
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async () => { asked += 1; },
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
logger: { warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('new', '/new'));
|
||||||
|
assert.equal(fixture.sessions.has('p2p:staff-approved'), false);
|
||||||
|
await bridge.accept(message('unsafe', '不应执行', {
|
||||||
|
sessionWebhook: 'https://oapi.dingtalk.com.attacker.example/reply?private=one',
|
||||||
|
}));
|
||||||
|
assert.equal(asked, 0);
|
||||||
|
assert.equal(sent[0], '已开启新会话。请发送你的问题。');
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.equal(bridge.status.lastError, '钉钉消息没有安全的回复地址。');
|
||||||
|
});
|
||||||
199
test/channels/dingtalk/dingtalk-card-stream.test.mjs
Normal file
199
test/channels/dingtalk/dingtalk-card-stream.test.mjs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createDingTalkCardStream } from '../../../src/channels/dingtalk/dingtalk-card-stream.mjs';
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
let reject;
|
||||||
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduler() {
|
||||||
|
let now = 0;
|
||||||
|
const tasks = [];
|
||||||
|
return {
|
||||||
|
clock: () => now,
|
||||||
|
timer: {
|
||||||
|
setTimeout(callback, delay) {
|
||||||
|
const task = { callback, at: now + delay };
|
||||||
|
tasks.push(task);
|
||||||
|
return task;
|
||||||
|
},
|
||||||
|
clearTimeout(task) {
|
||||||
|
const index = tasks.indexOf(task);
|
||||||
|
if (index >= 0) tasks.splice(index, 1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tasks,
|
||||||
|
async advance(milliseconds) {
|
||||||
|
now += milliseconds;
|
||||||
|
while (true) {
|
||||||
|
tasks.sort((left, right) => left.at - right.at);
|
||||||
|
const task = tasks[0];
|
||||||
|
if (!task || task.at > now) break;
|
||||||
|
tasks.shift();
|
||||||
|
task.callback();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(overrides = {}) {
|
||||||
|
const calls = { create: [], update: [], finish: [], fail: [] };
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
api: {
|
||||||
|
async createAiCard(request) {
|
||||||
|
calls.create.push(request);
|
||||||
|
return { cardInstanceId: 'card-one' };
|
||||||
|
},
|
||||||
|
async updateAiCard(request) {
|
||||||
|
calls.update.push(request);
|
||||||
|
},
|
||||||
|
async finishAiCard(request) {
|
||||||
|
calls.finish.push(request);
|
||||||
|
},
|
||||||
|
async failAiCard(request) {
|
||||||
|
calls.fail.push(request);
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStream(api, options = {}) {
|
||||||
|
return createDingTalkCardStream({
|
||||||
|
api,
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
target: { openConversationId: 'conversation-one' },
|
||||||
|
updateIntervalMs: 0,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('finish waits for an in-flight update and discards stale pending progress', async () => {
|
||||||
|
const activeUpdate = deferred();
|
||||||
|
const fixtureValue = fixture({
|
||||||
|
async updateAiCard(request) {
|
||||||
|
fixtureValue.calls.update.push(request);
|
||||||
|
await activeUpdate.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stream = createStream(fixtureValue.api, { logger: { error() {} } });
|
||||||
|
|
||||||
|
assert.equal(await stream.start('正在处理'), true);
|
||||||
|
stream.push('第一段');
|
||||||
|
stream.push('应丢弃的旧进度');
|
||||||
|
const finishing = stream.finish('最终答案');
|
||||||
|
|
||||||
|
assert.equal(fixtureValue.calls.finish.length, 0);
|
||||||
|
activeUpdate.resolve();
|
||||||
|
assert.equal(await finishing, true);
|
||||||
|
assert.deepEqual(fixtureValue.calls.create, [{
|
||||||
|
clientId: 'ding-client',
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
target: { openConversationId: 'conversation-one' },
|
||||||
|
initialText: '正在处理',
|
||||||
|
signal: undefined,
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(fixtureValue.calls.update.map(({ text, finished }) => ({ text, finished })), [
|
||||||
|
{ text: '第一段', finished: false },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(fixtureValue.calls.finish.map(({ text }) => text), ['最终答案']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('progress buffering sends only the latest text after the throttle delay', async () => {
|
||||||
|
const timers = scheduler();
|
||||||
|
const fixtureValue = fixture();
|
||||||
|
const stream = createStream(fixtureValue.api, {
|
||||||
|
updateIntervalMs: 800,
|
||||||
|
clock: timers.clock,
|
||||||
|
timer: timers.timer,
|
||||||
|
});
|
||||||
|
|
||||||
|
await stream.start('开始');
|
||||||
|
stream.push('草稿一');
|
||||||
|
stream.push('草稿二');
|
||||||
|
stream.push('草稿三');
|
||||||
|
|
||||||
|
assert.equal(timers.tasks.length, 1);
|
||||||
|
await timers.advance(799);
|
||||||
|
assert.equal(fixtureValue.calls.update.length, 0);
|
||||||
|
await timers.advance(1);
|
||||||
|
assert.deepEqual(fixtureValue.calls.update.map(({ text }) => text), ['草稿三']);
|
||||||
|
assert.equal(await stream.finish('完成'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an API failure permanently closes the stream without exposing request data', async () => {
|
||||||
|
const logged = [];
|
||||||
|
const fixtureValue = fixture({
|
||||||
|
async updateAiCard(request) {
|
||||||
|
fixtureValue.calls.update.push(request);
|
||||||
|
throw new Error(`remote rejected ${request.clientSecret}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stream = createStream(fixtureValue.api, {
|
||||||
|
logger: { error: (...args) => logged.push(args) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await stream.start('开始');
|
||||||
|
stream.push('失败的更新');
|
||||||
|
assert.equal(await stream.finish('不会发送'), false);
|
||||||
|
stream.push('失败后忽略');
|
||||||
|
assert.equal(await stream.finish('仍然不会发送'), false);
|
||||||
|
|
||||||
|
assert.equal(fixtureValue.calls.update.length, 1);
|
||||||
|
assert.equal(fixtureValue.calls.finish.length, 0);
|
||||||
|
assert.equal(fixtureValue.calls.fail.length, 1);
|
||||||
|
assert.deepEqual(logged, [['[dsh-dingtalk] AI Card update failed']]);
|
||||||
|
assert.doesNotMatch(JSON.stringify(logged), /host-secret|conversation-one/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('abort cancels a scheduled update and prevents finalization', async () => {
|
||||||
|
const timers = scheduler();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const fixtureValue = fixture();
|
||||||
|
const stream = createStream(fixtureValue.api, {
|
||||||
|
signal: controller.signal,
|
||||||
|
updateIntervalMs: 800,
|
||||||
|
clock: timers.clock,
|
||||||
|
timer: timers.timer,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(await stream.start('开始'), true);
|
||||||
|
stream.push('等待发送');
|
||||||
|
assert.equal(timers.tasks.length, 1);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
assert.equal(timers.tasks.length, 0);
|
||||||
|
await timers.advance(800);
|
||||||
|
assert.equal(fixtureValue.calls.update.length, 0);
|
||||||
|
assert.equal(await stream.finish('不会完成'), false);
|
||||||
|
assert.equal(fixtureValue.calls.finish.length, 0);
|
||||||
|
assert.equal(fixtureValue.calls.fail.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed final frame closes the delivered card once and requests text fallback', async () => {
|
||||||
|
const fixtureValue = fixture({
|
||||||
|
async finishAiCard(request) {
|
||||||
|
fixtureValue.calls.finish.push(request);
|
||||||
|
throw new Error('final frame rejected');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stream = createStream(fixtureValue.api, { logger: { error() {} } });
|
||||||
|
|
||||||
|
assert.equal(await stream.start('开始'), true);
|
||||||
|
assert.equal(await stream.finish('最终答案'), false);
|
||||||
|
|
||||||
|
assert.equal(fixtureValue.calls.finish.length, 1);
|
||||||
|
assert.equal(fixtureValue.calls.fail.length, 1);
|
||||||
|
assert.equal(fixtureValue.calls.fail[0].text, '消息处理失败,请稍后重试。');
|
||||||
|
assert.notEqual(fixtureValue.calls.fail[0].signal, fixtureValue.calls.finish[0].signal);
|
||||||
|
});
|
||||||
428
test/channels/dingtalk/dingtalk-controller.test.mjs
Normal file
428
test/channels/dingtalk/dingtalk-controller.test.mjs
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deriveDingtalkBotIdentity,
|
||||||
|
} from '../../../src/channels/dingtalk/config-store.mjs';
|
||||||
|
import { DingtalkController } from '../../../src/channels/dingtalk/dingtalk-controller.mjs';
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
function credentialsFixture(events = []) {
|
||||||
|
const values = new Map();
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
provider: {
|
||||||
|
resolve: async (ref) => values.has(ref)
|
||||||
|
? { configured: true, source: 'settings', value: values.get(ref) }
|
||||||
|
: { configured: false },
|
||||||
|
set: async (ref, value) => {
|
||||||
|
events.push(['set', ref]);
|
||||||
|
values.set(ref, value);
|
||||||
|
},
|
||||||
|
unset: async (ref) => {
|
||||||
|
events.push(['unset', ref]);
|
||||||
|
values.delete(ref);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configFixture(initial = [], events = []) {
|
||||||
|
const bots = new Map(initial.map((bot) => [bot.botId, structuredClone(bot)]));
|
||||||
|
return {
|
||||||
|
bots,
|
||||||
|
store: {
|
||||||
|
list: () => [...bots.values()].map((bot) => structuredClone(bot)),
|
||||||
|
get: (botId) => bots.has(botId) ? structuredClone(bots.get(botId)) : null,
|
||||||
|
getByClientId: (clientId) => {
|
||||||
|
const found = [...bots.values()].find((bot) => bot.clientId === clientId);
|
||||||
|
return found ? structuredClone(found) : null;
|
||||||
|
},
|
||||||
|
save: async (bot) => {
|
||||||
|
events.push(['save', bot.botId]);
|
||||||
|
bots.set(bot.botId, structuredClone(bot));
|
||||||
|
return structuredClone(bot);
|
||||||
|
},
|
||||||
|
remove: async (botId) => {
|
||||||
|
events.push(['remove', botId]);
|
||||||
|
const value = bots.get(botId) ?? null;
|
||||||
|
bots.delete(botId);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeFactory({ events = [], pendingByClient = new Map(), failStart = false } = {}) {
|
||||||
|
const runtimes = [];
|
||||||
|
return {
|
||||||
|
runtimes,
|
||||||
|
createRuntime: async ({ botId, config, clientSecret }) => {
|
||||||
|
events.push(['create', botId]);
|
||||||
|
let ready = false;
|
||||||
|
const approvedIds = new Set(config.approvedSenders.map((sender) => sender.staffId));
|
||||||
|
const pending = (pendingByClient.get(config.clientId) ?? [])
|
||||||
|
.filter((sender) => !approvedIds.has(sender.staffId));
|
||||||
|
const runtime = {
|
||||||
|
botId,
|
||||||
|
config,
|
||||||
|
clientSecret,
|
||||||
|
get status() {
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
state: ready ? 'connected' : 'offline',
|
||||||
|
pendingSenders: pending,
|
||||||
|
messagesReceived: 2,
|
||||||
|
messagesReplied: 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
pendingSender(requestId) {
|
||||||
|
return pending.find((sender) => sender.requestId === requestId) ?? null;
|
||||||
|
},
|
||||||
|
async start() {
|
||||||
|
events.push(['start', botId]);
|
||||||
|
if (typeof failStart === 'function' ? failStart() : failStart) {
|
||||||
|
throw new Error('runtime failure containing private-secret');
|
||||||
|
}
|
||||||
|
ready = true;
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
events.push(['stop', botId]);
|
||||||
|
ready = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runtimes.push(runtime);
|
||||||
|
return runtime;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function successfulDeviceAuth(clientId = 'ding-client-private', clientSecret = 'client-secret-private') {
|
||||||
|
return {
|
||||||
|
start: async () => ({
|
||||||
|
deviceCode: 'device-code-private',
|
||||||
|
verificationUrl: 'https://oapi.dingtalk.com/verify?code=public-qr-code',
|
||||||
|
expiresAt: 100_000,
|
||||||
|
pollIntervalMs: 1_000,
|
||||||
|
}),
|
||||||
|
poll: async ({ deviceCode }) => {
|
||||||
|
assert.equal(deviceCode, 'device-code-private');
|
||||||
|
return { status: 'SUCCESS', clientId, clientSecret };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('successful QR poll stores secret then config then starts runtime without public leakage', async () => {
|
||||||
|
const events = [];
|
||||||
|
const credentials = credentialsFixture(events);
|
||||||
|
const configs = configFixture([], events);
|
||||||
|
const runtimes = runtimeFactory({ events });
|
||||||
|
const controller = new DingtalkController({
|
||||||
|
deviceAuth: successfulDeviceAuth(),
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimes.createRuntime,
|
||||||
|
clock: () => 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const begun = await controller.startProvisioning();
|
||||||
|
assert.equal(begun.status, 'pending');
|
||||||
|
assert.doesNotMatch(JSON.stringify(begun), /device-code-private|client-secret-private|secretRef/);
|
||||||
|
const completed = await controller.registrationStatus(begun.attemptId);
|
||||||
|
|
||||||
|
assert.equal(completed.status, 'connected');
|
||||||
|
assert.match(completed.botId, /^dt_[a-f0-9]{24}$/);
|
||||||
|
assert.deepEqual(events.slice(0, 4).map(([event]) => event), ['set', 'save', 'create', 'start']);
|
||||||
|
assert.equal(credentials.values.size, 1);
|
||||||
|
assert.equal([...credentials.values.values()][0], 'client-secret-private');
|
||||||
|
assert.equal(configs.bots.size, 1);
|
||||||
|
assert.equal(runtimes.runtimes[0].clientSecret, 'client-secret-private');
|
||||||
|
|
||||||
|
const publicJson = JSON.stringify(controller.status());
|
||||||
|
assert.doesNotMatch(
|
||||||
|
publicJson,
|
||||||
|
/device-code-private|client-secret-private|secretRef|ding-client-private/,
|
||||||
|
);
|
||||||
|
assert.equal(controller.status().totals.connected, 1);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scanning the same client ID is idempotent and replaces its one runtime', async () => {
|
||||||
|
const events = [];
|
||||||
|
const credentials = credentialsFixture(events);
|
||||||
|
const configs = configFixture([], events);
|
||||||
|
const runtimes = runtimeFactory({ events });
|
||||||
|
const controller = new DingtalkController({
|
||||||
|
deviceAuth: successfulDeviceAuth(),
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimes.createRuntime,
|
||||||
|
clock: () => 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await controller.startProvisioning();
|
||||||
|
await controller.registrationStatus(first.attemptId);
|
||||||
|
const second = await controller.startProvisioning();
|
||||||
|
const completed = await controller.registrationStatus(second.attemptId);
|
||||||
|
|
||||||
|
assert.equal(completed.alreadyConnected, true);
|
||||||
|
assert.equal(configs.bots.size, 1);
|
||||||
|
assert.equal(credentials.values.size, 1);
|
||||||
|
assert.equal(controller.status().bots.length, 1);
|
||||||
|
assert.equal(runtimes.runtimes.length, 2);
|
||||||
|
assert.equal(runtimes.runtimes[0].status.ready, false);
|
||||||
|
assert.equal(runtimes.runtimes[1].status.ready, true);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sender approval uses opaque request and sender keys while raw staff IDs stay host-only', async () => {
|
||||||
|
const events = [];
|
||||||
|
const identity = deriveDingtalkBotIdentity('ding-client-one');
|
||||||
|
const config = {
|
||||||
|
...identity,
|
||||||
|
clientId: 'ding-client-one',
|
||||||
|
approvedSenders: [],
|
||||||
|
};
|
||||||
|
const credentials = credentialsFixture(events);
|
||||||
|
credentials.values.set(identity.secretRef, 'client-secret-private');
|
||||||
|
const configs = configFixture([config], events);
|
||||||
|
const pendingByClient = new Map([[
|
||||||
|
'ding-client-one',
|
||||||
|
[{
|
||||||
|
staffId: 'staff-private-one',
|
||||||
|
senderNick: '待审批用户',
|
||||||
|
requestedAt: '2026-08-15T00:00:00.000Z',
|
||||||
|
requestId: 'ding_sender_abc123',
|
||||||
|
}],
|
||||||
|
]]);
|
||||||
|
const runtimes = runtimeFactory({ events, pendingByClient });
|
||||||
|
const controller = new DingtalkController({
|
||||||
|
deviceAuth: successfulDeviceAuth(),
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimes.createRuntime,
|
||||||
|
clock: () => Date.parse('2026-08-15T01:00:00.000Z'),
|
||||||
|
});
|
||||||
|
await controller.initialize();
|
||||||
|
|
||||||
|
const pending = controller.status().bots[0].senders.pending[0];
|
||||||
|
assert.equal(pending.requestId, 'ding_sender_abc123');
|
||||||
|
assert.equal(pending.displayName, '待审批用户');
|
||||||
|
assert.doesNotMatch(JSON.stringify(controller.status()), /staff-private-one|secretRef/);
|
||||||
|
|
||||||
|
await controller.approveSender(identity.botId, pending.requestId);
|
||||||
|
const approvedStatus = controller.status().bots[0].senders;
|
||||||
|
assert.equal(approvedStatus.pending.length, 0);
|
||||||
|
assert.equal(approvedStatus.approved.length, 1);
|
||||||
|
assert.match(approvedStatus.approved[0].senderKey, /^dt_sender_[a-f0-9]{32}$/);
|
||||||
|
assert.equal(approvedStatus.approved[0].approvedAt, '2026-08-15T01:00:00.000Z');
|
||||||
|
assert.equal(configs.bots.get(identity.botId).approvedSenders[0].staffId, 'staff-private-one');
|
||||||
|
assert.doesNotMatch(JSON.stringify(controller.status()), /staff-private-one|client-secret-private/);
|
||||||
|
|
||||||
|
await controller.revokeSender(identity.botId, approvedStatus.approved[0].senderKey);
|
||||||
|
assert.equal(configs.bots.get(identity.botId).approvedSenders.length, 0);
|
||||||
|
assert.equal(controller.status().bots[0].senders.approved.length, 0);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime activation failure retains the authorized bot for reconnect without exposing detail', async () => {
|
||||||
|
let startCount = 0;
|
||||||
|
const events = [];
|
||||||
|
const credentials = credentialsFixture(events);
|
||||||
|
const configs = configFixture([], events);
|
||||||
|
const runtimes = runtimeFactory({ events, failStart: () => startCount++ === 0 });
|
||||||
|
const controller = new DingtalkController({
|
||||||
|
deviceAuth: successfulDeviceAuth(),
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimes.createRuntime,
|
||||||
|
logger: { error() {}, warn() {} },
|
||||||
|
clock: () => 1_000,
|
||||||
|
});
|
||||||
|
const begun = await controller.startProvisioning();
|
||||||
|
const completed = await controller.registrationStatus(begun.attemptId);
|
||||||
|
|
||||||
|
assert.equal(completed.status, 'connected');
|
||||||
|
assert.equal(credentials.values.size, 1);
|
||||||
|
assert.equal(configs.bots.size, 1);
|
||||||
|
const status = controller.status();
|
||||||
|
assert.deepEqual(status.totals, { configured: 1, connected: 0 });
|
||||||
|
assert.equal(status.bots[0].state, 'error');
|
||||||
|
assert.equal(status.bots[0].error.code, 'connection-failed');
|
||||||
|
assert.equal(events.some(([event]) => event === 'unset' || event === 'remove'), false);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
JSON.stringify({ completed, status }),
|
||||||
|
/private-secret|client-secret-private|device-code-private|secretRef/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reconnected = await controller.reconnectBot(completed.botId);
|
||||||
|
assert.deepEqual(reconnected.totals, { configured: 1, connected: 1 });
|
||||||
|
assert.equal(reconnected.bots[0].state, 'connected');
|
||||||
|
assert.equal(reconnected.bots[0].error, null);
|
||||||
|
assert.equal(credentials.values.size, 1);
|
||||||
|
assert.equal(configs.bots.size, 1);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config persistence failure rolls back the newly stored credential', async () => {
|
||||||
|
const credentials = credentialsFixture();
|
||||||
|
const configs = configFixture();
|
||||||
|
const controller = new DingtalkController({
|
||||||
|
deviceAuth: successfulDeviceAuth(),
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: {
|
||||||
|
...configs.store,
|
||||||
|
save: async () => {
|
||||||
|
throw new Error('config failure containing private-secret');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createRuntime: runtimeFactory().createRuntime,
|
||||||
|
logger: { error() {}, warn() {} },
|
||||||
|
clock: () => 1_000,
|
||||||
|
});
|
||||||
|
const begun = await controller.startProvisioning();
|
||||||
|
const failed = await controller.registrationStatus(begun.attemptId);
|
||||||
|
|
||||||
|
assert.equal(failed.status, 'failed');
|
||||||
|
assert.equal(failed.error.code, 'activation-failed');
|
||||||
|
assert.equal(credentials.values.size, 0);
|
||||||
|
assert.equal(configs.bots.size, 0);
|
||||||
|
assert.doesNotMatch(JSON.stringify(failed), /private-secret|client-secret-private|device-code-private/);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling while the persisted bot is starting rolls its binding back', async () => {
|
||||||
|
const credentials = credentialsFixture();
|
||||||
|
const configs = configFixture();
|
||||||
|
let enteredStart;
|
||||||
|
let releaseStart;
|
||||||
|
const startEntered = new Promise((resolve) => { enteredStart = resolve; });
|
||||||
|
const controller = new DingtalkController({
|
||||||
|
deviceAuth: successfulDeviceAuth(),
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: async () => ({
|
||||||
|
status: { ready: false },
|
||||||
|
start: async () => new Promise((resolve) => {
|
||||||
|
releaseStart = resolve;
|
||||||
|
enteredStart();
|
||||||
|
}),
|
||||||
|
stop: async () => {},
|
||||||
|
}),
|
||||||
|
logger: { error() {}, warn() {} },
|
||||||
|
clock: () => 1_000,
|
||||||
|
});
|
||||||
|
const begun = await controller.startProvisioning();
|
||||||
|
const polling = controller.registrationStatus(begun.attemptId);
|
||||||
|
await startEntered;
|
||||||
|
assert.equal(credentials.values.size, 1);
|
||||||
|
assert.equal(configs.bots.size, 1);
|
||||||
|
|
||||||
|
const cancelling = controller.cancelProvisioning(begun.attemptId);
|
||||||
|
releaseStart();
|
||||||
|
const [cancelled, polled] = await Promise.all([cancelling, polling]);
|
||||||
|
|
||||||
|
assert.equal(cancelled.status, 'cancelled');
|
||||||
|
assert.equal(polled.status, 'cancelled');
|
||||||
|
assert.equal(credentials.values.size, 0);
|
||||||
|
assert.equal(configs.bots.size, 0);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling an in-flight poll aborts it and writes no credential', async () => {
|
||||||
|
const credentials = credentialsFixture();
|
||||||
|
const configs = configFixture();
|
||||||
|
const controller = new DingtalkController({
|
||||||
|
deviceAuth: {
|
||||||
|
start: successfulDeviceAuth().start,
|
||||||
|
poll: async ({ signal }) => new Promise((_resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimeFactory().createRuntime,
|
||||||
|
clock: () => 1_000,
|
||||||
|
});
|
||||||
|
const begun = await controller.startProvisioning();
|
||||||
|
const polling = controller.registrationStatus(begun.attemptId);
|
||||||
|
await flush();
|
||||||
|
const cancelled = await controller.cancelProvisioning(begun.attemptId);
|
||||||
|
|
||||||
|
assert.equal(cancelled.status, 'cancelled');
|
||||||
|
assert.equal((await polling).status, 'cancelled');
|
||||||
|
assert.equal(credentials.values.size, 0);
|
||||||
|
assert.equal(configs.bots.size, 0);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initialize manages multiple configured bot runtimes independently', async () => {
|
||||||
|
const firstIdentity = deriveDingtalkBotIdentity('ding-client-one');
|
||||||
|
const secondIdentity = deriveDingtalkBotIdentity('ding-client-two');
|
||||||
|
const configs = configFixture([
|
||||||
|
{ ...firstIdentity, clientId: 'ding-client-one', approvedSenders: [] },
|
||||||
|
{ ...secondIdentity, clientId: 'ding-client-two', approvedSenders: [] },
|
||||||
|
]);
|
||||||
|
const credentials = credentialsFixture();
|
||||||
|
credentials.values.set(firstIdentity.secretRef, 'secret-one');
|
||||||
|
credentials.values.set(secondIdentity.secretRef, 'secret-two');
|
||||||
|
const runtimes = runtimeFactory();
|
||||||
|
const controller = new DingtalkController({
|
||||||
|
deviceAuth: successfulDeviceAuth(),
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimes.createRuntime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await controller.initialize();
|
||||||
|
assert.deepEqual(status.totals, { configured: 2, connected: 2 });
|
||||||
|
assert.equal(runtimes.runtimes.length, 2);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close waits for an in-flight transition and leaves no connected runtime behind', async () => {
|
||||||
|
const identity = deriveDingtalkBotIdentity('ding-client-close');
|
||||||
|
const configs = configFixture([{
|
||||||
|
...identity,
|
||||||
|
clientId: 'ding-client-close',
|
||||||
|
approvedSenders: [],
|
||||||
|
}]);
|
||||||
|
const credentials = credentialsFixture();
|
||||||
|
credentials.values.set(identity.secretRef, 'secret-close');
|
||||||
|
let rejectStart;
|
||||||
|
let stops = 0;
|
||||||
|
const controller = new DingtalkController({
|
||||||
|
deviceAuth: successfulDeviceAuth(),
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: async () => ({
|
||||||
|
status: { ready: false },
|
||||||
|
start: async () => new Promise((_, reject) => { rejectStart = reject; }),
|
||||||
|
stop: async () => {
|
||||||
|
stops += 1;
|
||||||
|
rejectStart?.(new DOMException('Runtime stopped', 'AbortError'));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
logger: { warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
const initializing = controller.initialize();
|
||||||
|
await flush();
|
||||||
|
const closing = controller.close();
|
||||||
|
await Promise.race([
|
||||||
|
closing,
|
||||||
|
new Promise((_, reject) => setTimeout(
|
||||||
|
() => reject(new Error('controller close did not stop the provisional runtime')),
|
||||||
|
100,
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
await Promise.allSettled([initializing, closing]);
|
||||||
|
|
||||||
|
assert.ok(stops >= 1);
|
||||||
|
assert.equal(controller.status().totals.connected, 0);
|
||||||
|
await assert.rejects(
|
||||||
|
controller.reconnectBot(identity.botId),
|
||||||
|
/controller is closed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
368
test/channels/dingtalk/dingtalk-runtime.test.mjs
Normal file
368
test/channels/dingtalk/dingtalk-runtime.test.mjs
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { DingtalkRuntime } from '../../../src/channels/dingtalk/dingtalk-runtime.mjs';
|
||||||
|
|
||||||
|
async function eventually(predicate, timeoutMs = 1_000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (predicate()) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
assert.fail('condition did not become true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateFixture() {
|
||||||
|
const sessions = new Map();
|
||||||
|
const seen = new Set();
|
||||||
|
const pending = new Map();
|
||||||
|
return {
|
||||||
|
hasSeen: (id) => seen.has(id),
|
||||||
|
markSeen: async (id) => seen.add(id),
|
||||||
|
sessionFor: (key) => sessions.get(key) ?? null,
|
||||||
|
setSession: async (key, value) => sessions.set(key, value),
|
||||||
|
clearSession: async (key) => sessions.delete(key),
|
||||||
|
pendingSenders: () => [...pending.values()],
|
||||||
|
pendingSender: (requestId) => pending.get(requestId) ?? null,
|
||||||
|
recordPendingSender: async ({ staffId, displayName, lastSeenAt }) => {
|
||||||
|
const entry = {
|
||||||
|
requestId: `request-${staffId}`,
|
||||||
|
staffId,
|
||||||
|
displayName,
|
||||||
|
requestedAt: lastSeenAt,
|
||||||
|
lastSeenAt,
|
||||||
|
};
|
||||||
|
pending.set(entry.requestId, entry);
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
|
removePendingSenderByStaffId: async (staffId) => {
|
||||||
|
const entry = [...pending.values()].find((value) => value.staffId === staffId);
|
||||||
|
if (!entry) return false;
|
||||||
|
pending.delete(entry.requestId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runtime owns one DWClient, waits for socket OPEN, acknowledges first, and disconnects on stop', async () => {
|
||||||
|
const order = [];
|
||||||
|
const state = stateFixture();
|
||||||
|
await state.recordPendingSender({
|
||||||
|
staffId: 'staff-approved',
|
||||||
|
displayName: '已批准用户',
|
||||||
|
lastSeenAt: '2026-08-15T01:00:00.000Z',
|
||||||
|
});
|
||||||
|
let callback;
|
||||||
|
const client = {
|
||||||
|
connected: false,
|
||||||
|
socket: { readyState: 0 },
|
||||||
|
registerCallbackListener(topic, listener) {
|
||||||
|
order.push(['register', topic]);
|
||||||
|
callback = listener;
|
||||||
|
},
|
||||||
|
async connect() {
|
||||||
|
order.push(['connect']);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connected = true;
|
||||||
|
this.socket.readyState = 1;
|
||||||
|
}, 10);
|
||||||
|
},
|
||||||
|
socketCallBackResponse(messageId, body) {
|
||||||
|
order.push(['ack', messageId, body]);
|
||||||
|
},
|
||||||
|
disconnect() {
|
||||||
|
order.push(['disconnect']);
|
||||||
|
this.connected = false;
|
||||||
|
this.socket.readyState = 3;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const runtime = new DingtalkRuntime({
|
||||||
|
config: {
|
||||||
|
clientId: 'ding-client',
|
||||||
|
approvedSenders: [{ staffId: 'staff-approved' }],
|
||||||
|
},
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
ensureRunning: async () => order.push(['harness-ready']),
|
||||||
|
sessionExists: async () => true,
|
||||||
|
createSession: async () => 'session-one',
|
||||||
|
ask: async () => {
|
||||||
|
order.push(['ask']);
|
||||||
|
return 'Harness 回答';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
api: {
|
||||||
|
sendText: async ({ text }) => order.push(['send', text]),
|
||||||
|
},
|
||||||
|
streamFactory: async ({ clientId, clientSecret }) => {
|
||||||
|
assert.equal(clientId, 'ding-client');
|
||||||
|
assert.equal(clientSecret, 'host-secret');
|
||||||
|
return { client, topic: 'robot-topic' };
|
||||||
|
},
|
||||||
|
connectPollIntervalMs: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = await runtime.start();
|
||||||
|
assert.equal(started.ready, true);
|
||||||
|
assert.equal(started.dingtalkStreamState, 'connected');
|
||||||
|
assert.deepEqual(started.pendingSenders, []);
|
||||||
|
assert.deepEqual(order.slice(0, 3), [
|
||||||
|
['harness-ready'],
|
||||||
|
['register', 'robot-topic'],
|
||||||
|
['connect'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
headers: { messageId: 'callback-one' },
|
||||||
|
data: JSON.stringify({
|
||||||
|
msgId: 'business-one',
|
||||||
|
msgtype: 'text',
|
||||||
|
text: { content: '问题' },
|
||||||
|
conversationType: '1',
|
||||||
|
senderStaffId: 'staff-approved',
|
||||||
|
senderNick: '用户',
|
||||||
|
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=one',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.deepEqual(order.at(-1), ['ack', 'callback-one', { success: true }]);
|
||||||
|
await eventually(() => runtime.status.messagesReplied === 1);
|
||||||
|
assert.ok(order.findIndex(([action]) => action === 'ack') < order.findIndex(([action]) => action === 'ask'));
|
||||||
|
assert.ok(order.findIndex(([action]) => action === 'ack') < order.findIndex(([action]) => action === 'send'));
|
||||||
|
|
||||||
|
await runtime.stop();
|
||||||
|
assert.deepEqual(order.at(-1), ['disconnect']);
|
||||||
|
assert.equal(runtime.status.ready, false);
|
||||||
|
assert.equal(runtime.status.dingtalkStreamState, 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime sends visible-scope messages to Harness without local sender approval', async () => {
|
||||||
|
const state = stateFixture();
|
||||||
|
const asked = [];
|
||||||
|
const sent = [];
|
||||||
|
let callback;
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
socket: { readyState: 1 },
|
||||||
|
registerCallbackListener(_topic, listener) { callback = listener; },
|
||||||
|
async connect() {},
|
||||||
|
socketCallBackResponse() {},
|
||||||
|
disconnect() {},
|
||||||
|
};
|
||||||
|
const runtime = new DingtalkRuntime({
|
||||||
|
config: { clientId: 'ding-client', approvedSenders: [] },
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
ensureRunning: async () => true,
|
||||||
|
sessionExists: async () => false,
|
||||||
|
createSession: async () => 'session-visible-sender',
|
||||||
|
ask: async (sessionId, text) => {
|
||||||
|
asked.push({ sessionId, text });
|
||||||
|
return '直接回答';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
api: { sendText: async ({ text }) => sent.push(text) },
|
||||||
|
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||||
|
});
|
||||||
|
await runtime.start();
|
||||||
|
callback({
|
||||||
|
headers: { messageId: 'callback-pending' },
|
||||||
|
data: JSON.stringify({
|
||||||
|
msgId: 'business-pending',
|
||||||
|
msgtype: 'text',
|
||||||
|
text: { content: '请求使用' },
|
||||||
|
conversationType: '1',
|
||||||
|
senderStaffId: 'raw-staff-id',
|
||||||
|
senderNick: '可见范围用户',
|
||||||
|
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=pending',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await eventually(() => runtime.status.messagesReplied === 1);
|
||||||
|
assert.deepEqual(asked, [{
|
||||||
|
sessionId: 'session-visible-sender',
|
||||||
|
text: '请求使用',
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(sent, ['直接回答']);
|
||||||
|
assert.deepEqual(runtime.pendingSenders(), []);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime accepts an OPEN DingTalk socket when the SDK registered flag remains false', async () => {
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
registered: false,
|
||||||
|
socket: { readyState: 1 },
|
||||||
|
registerCallbackListener() {},
|
||||||
|
async connect() {},
|
||||||
|
socketCallBackResponse() {},
|
||||||
|
disconnect() {},
|
||||||
|
};
|
||||||
|
const runtime = new DingtalkRuntime({
|
||||||
|
config: { clientId: 'ding-client', approvedSenders: [] },
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: { ensureRunning: async () => true },
|
||||||
|
state: stateFixture(),
|
||||||
|
api: { sendText: async () => true },
|
||||||
|
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal((await runtime.start()).ready, true);
|
||||||
|
assert.equal(client.registered, false);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime never reports ready when connect resolves before the socket opens permanently', async () => {
|
||||||
|
let disconnects = 0;
|
||||||
|
const client = {
|
||||||
|
connected: false,
|
||||||
|
socket: { readyState: 0 },
|
||||||
|
registerCallbackListener() {},
|
||||||
|
async connect() {},
|
||||||
|
socketCallBackResponse() {},
|
||||||
|
disconnect() { disconnects += 1; },
|
||||||
|
};
|
||||||
|
const runtime = new DingtalkRuntime({
|
||||||
|
config: { clientId: 'ding-client', approvedSenders: [] },
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: { ensureRunning: async () => true },
|
||||||
|
state: stateFixture(),
|
||||||
|
api: { sendText: async () => true },
|
||||||
|
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||||
|
connectTimeoutMs: 15,
|
||||||
|
connectPollIntervalMs: 2,
|
||||||
|
logger: { warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(runtime.start(), /handshake timed out/);
|
||||||
|
assert.equal(disconnects, 1);
|
||||||
|
assert.equal(runtime.status.ready, false);
|
||||||
|
assert.equal(runtime.status.dingtalkStreamState, 'failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime bounds a stalled SDK gateway lookup and disconnects a late connection', async () => {
|
||||||
|
let finishConnect;
|
||||||
|
let disconnects = 0;
|
||||||
|
const client = {
|
||||||
|
connected: false,
|
||||||
|
socket: { readyState: 0 },
|
||||||
|
registerCallbackListener() {},
|
||||||
|
connect: async () => new Promise((resolve) => { finishConnect = resolve; }),
|
||||||
|
socketCallBackResponse() {},
|
||||||
|
disconnect() { disconnects += 1; },
|
||||||
|
};
|
||||||
|
const runtime = new DingtalkRuntime({
|
||||||
|
config: { clientId: 'ding-client', approvedSenders: [] },
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: { ensureRunning: async () => true },
|
||||||
|
state: stateFixture(),
|
||||||
|
api: { sendText: async () => true },
|
||||||
|
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||||
|
connectTimeoutMs: 10,
|
||||||
|
connectPollIntervalMs: 2,
|
||||||
|
logger: { warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(runtime.start(), /handshake timed out after 10ms/);
|
||||||
|
assert.equal(disconnects, 1);
|
||||||
|
finishConnect();
|
||||||
|
await eventually(() => disconnects === 2);
|
||||||
|
assert.equal(runtime.status.ready, false);
|
||||||
|
assert.equal(runtime.status.dingtalkStreamState, 'failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a callback from a stopped stream is not acknowledged or processed', async () => {
|
||||||
|
const events = [];
|
||||||
|
let callback;
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
socket: { readyState: 1 },
|
||||||
|
registerCallbackListener(_topic, listener) { callback = listener; },
|
||||||
|
async connect() {},
|
||||||
|
socketCallBackResponse() { events.push('ack'); },
|
||||||
|
disconnect() { this.connected = false; this.socket.readyState = 3; },
|
||||||
|
};
|
||||||
|
const runtime = new DingtalkRuntime({
|
||||||
|
config: { clientId: 'ding-client', approvedSenders: [{ staffId: 'approved' }] },
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: { ensureRunning: async () => true, ask: async () => events.push('ask') },
|
||||||
|
state: stateFixture(),
|
||||||
|
api: { sendText: async () => events.push('send') },
|
||||||
|
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||||
|
});
|
||||||
|
await runtime.start();
|
||||||
|
await runtime.stop();
|
||||||
|
|
||||||
|
callback({
|
||||||
|
headers: { messageId: 'late-callback' },
|
||||||
|
data: JSON.stringify({
|
||||||
|
msgId: 'late-message',
|
||||||
|
msgtype: 'text',
|
||||||
|
text: { content: '不应处理' },
|
||||||
|
conversationType: '1',
|
||||||
|
senderStaffId: 'approved',
|
||||||
|
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=late',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(events, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop aborts in-flight Harness work without waiting for the reply timeout', async () => {
|
||||||
|
let callback;
|
||||||
|
let askStarted;
|
||||||
|
const askStartedPromise = new Promise((resolve) => { askStarted = resolve; });
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
socket: { readyState: 1 },
|
||||||
|
registerCallbackListener(_topic, listener) { callback = listener; },
|
||||||
|
async connect() {},
|
||||||
|
socketCallBackResponse() {},
|
||||||
|
disconnect() { this.connected = false; this.socket.readyState = 3; },
|
||||||
|
};
|
||||||
|
const runtime = new DingtalkRuntime({
|
||||||
|
config: { clientId: 'ding-client', approvedSenders: [{ staffId: 'approved' }] },
|
||||||
|
clientSecret: 'host-secret',
|
||||||
|
harness: {
|
||||||
|
ensureRunning: async () => true,
|
||||||
|
sessionExists: async () => true,
|
||||||
|
createSession: async () => 'session-one',
|
||||||
|
ask: async (_sessionId, _text, { signal }) => {
|
||||||
|
askStarted();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: stateFixture(),
|
||||||
|
api: { sendText: async () => true },
|
||||||
|
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||||
|
logger: { warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
await runtime.start();
|
||||||
|
callback({
|
||||||
|
headers: { messageId: 'callback-hanging' },
|
||||||
|
data: JSON.stringify({
|
||||||
|
msgId: 'business-hanging',
|
||||||
|
msgtype: 'text',
|
||||||
|
text: { content: '长时间问题' },
|
||||||
|
conversationType: '1',
|
||||||
|
senderStaffId: 'approved',
|
||||||
|
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=hanging',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await askStartedPromise;
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
runtime.stop(),
|
||||||
|
new Promise((_, reject) => setTimeout(
|
||||||
|
() => reject(new Error('runtime stop did not abort Harness work')),
|
||||||
|
100,
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(runtime.status.ready, false);
|
||||||
|
assert.equal(runtime.status.dingtalkStreamState, 'idle');
|
||||||
|
});
|
||||||
74
test/channels/dingtalk/harness-client.test.mjs
Normal file
74
test/channels/dingtalk/harness-client.test.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { HarnessClient, HarnessReplyTracker, HarnessRpcError } from '../../../src/channels/dingtalk/harness-client.mjs';
|
||||||
|
|
||||||
|
test('reply tracker associates only the Harness turn created by the DingTalk prompt RPC', () => {
|
||||||
|
const tracker = new HarnessReplyTracker({ promptRpcId: 'dingtalk-prompt', afterSeq: 2 });
|
||||||
|
const update = tracker.consume([
|
||||||
|
{ event: { seq: 3, type: 'turn/start', data: { turn: 9 } } },
|
||||||
|
{ event: {
|
||||||
|
seq: 4,
|
||||||
|
type: 'user/message',
|
||||||
|
data: { turn: 9, source: { rpcId: 'dingtalk-prompt' } },
|
||||||
|
} },
|
||||||
|
{ event: {
|
||||||
|
seq: 5,
|
||||||
|
type: 'assistant/chunk',
|
||||||
|
data: { turn: 9, step: 0, chunk: { type: 'text-delta', index: 0, text: '钉钉' } },
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(update, { type: 'text', text: '钉钉' });
|
||||||
|
tracker.consume([
|
||||||
|
{ event: {
|
||||||
|
seq: 6,
|
||||||
|
type: 'assistant/message',
|
||||||
|
data: { turn: 9, message: { content: [{ type: 'text', text: '钉钉回复完成' }] } },
|
||||||
|
} },
|
||||||
|
{ event: { seq: 7, type: 'turn/end', data: { turn: 9, reason: 'completed' } } },
|
||||||
|
]);
|
||||||
|
assert.equal(tracker.finished, true);
|
||||||
|
assert.equal(tracker.answer, '钉钉回复完成');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reply tracker ignores interleaved turns and events at or before the baseline', () => {
|
||||||
|
const tracker = new HarnessReplyTracker({ promptRpcId: 'target', afterSeq: 10 });
|
||||||
|
tracker.consume([
|
||||||
|
{ event: { seq: 9, type: 'turn/start', data: { turn: 1 } } },
|
||||||
|
{ event: { seq: 11, type: 'turn/start', data: { turn: 2 } } },
|
||||||
|
{ event: { seq: 12, type: 'user/message', data: { turn: 2, source: { rpcId: 'other' } } } },
|
||||||
|
{ event: {
|
||||||
|
seq: 13,
|
||||||
|
type: 'assistant/message',
|
||||||
|
data: { turn: 2, message: { content: [{ type: 'text', text: 'wrong' }] } },
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
assert.equal(tracker.answer, '');
|
||||||
|
assert.equal(tracker.finished, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Harness client validates the RPC envelope and preserves server error codes', async () => {
|
||||||
|
let request;
|
||||||
|
const client = new HarnessClient({
|
||||||
|
baseUrl: 'http://127.0.0.1:3080',
|
||||||
|
workspace: '/tmp/workspace',
|
||||||
|
fetchImpl: async (url, options) => {
|
||||||
|
request = { url: url.toString(), body: JSON.parse(options.body) };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
type: 'server-response',
|
||||||
|
rpcId: request.body.rpcId,
|
||||||
|
result: { ok: false, error: { code: 'session-not-found', message: 'missing' } },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
client.rpc('session.history', { sessionId: 'one' }),
|
||||||
|
(error) => error instanceof HarnessRpcError && error.code === 'session-not-found',
|
||||||
|
);
|
||||||
|
assert.equal(request.url, 'http://127.0.0.1:3080/api/session.history');
|
||||||
|
assert.match(request.body.rpcId, /^dingtalk-/);
|
||||||
|
});
|
||||||
44
test/channels/dingtalk/plugin-host.test.mjs
Normal file
44
test/channels/dingtalk/plugin-host.test.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { apply, createDingtalkHostPlugin, inject, name } from '../../../plugin-src/host/channels/dingtalk/index.mjs';
|
||||||
|
|
||||||
|
function controller() {
|
||||||
|
return {
|
||||||
|
status() { return { bots: [] }; },
|
||||||
|
startProvisioning() {},
|
||||||
|
registrationStatus() {},
|
||||||
|
cancelProvisioning() {},
|
||||||
|
reconnectBot() {},
|
||||||
|
deleteBot() {},
|
||||||
|
approveSender() {},
|
||||||
|
revokeSender() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Host exports the DingTalk plugin identity and required services', () => {
|
||||||
|
const plugin = createDingtalkHostPlugin({ controller: controller() });
|
||||||
|
assert.equal(name, 'dsh-dingtalk-host');
|
||||||
|
assert.deepEqual(inject, ['connection', 'credentials', 'webServer']);
|
||||||
|
assert.equal(plugin.name, name);
|
||||||
|
assert.deepEqual(plugin.inject, inject);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Host installs loopback RPC for an injected controller', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const dispose = () => {};
|
||||||
|
const ctx = {
|
||||||
|
connection: {
|
||||||
|
rpc: {
|
||||||
|
handle: (...args) => {
|
||||||
|
calls.push(args);
|
||||||
|
return dispose;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(await apply(ctx, { controller: controller() }), dispose);
|
||||||
|
assert.equal(calls[0][0], '/dingtalk');
|
||||||
|
assert.deepEqual(calls[0][2], { authority: 'loopback' });
|
||||||
|
});
|
||||||
74
test/channels/dingtalk/production.test.mjs
Normal file
74
test/channels/dingtalk/production.test.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createProductionController } from '../../../plugin-src/host/channels/dingtalk/production.mjs';
|
||||||
|
|
||||||
|
test('production assembly keeps secrets in credentials and creates per-bot runtimes', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'dsh-dingtalk-production-'));
|
||||||
|
const seen = {};
|
||||||
|
class ConfigStore {
|
||||||
|
constructor(path) { seen.configPath = path; }
|
||||||
|
async load() { return this; }
|
||||||
|
}
|
||||||
|
class DeviceAuth {
|
||||||
|
constructor(options) { seen.deviceAuthOptions = options; }
|
||||||
|
}
|
||||||
|
class StateStore {
|
||||||
|
constructor(path) { seen.statePath = path; }
|
||||||
|
async load() { return this; }
|
||||||
|
}
|
||||||
|
class Harness {
|
||||||
|
constructor(options) { seen.harnessOptions = options; }
|
||||||
|
stopManagedProcess() { seen.harnessStopped = true; }
|
||||||
|
}
|
||||||
|
class Runtime {
|
||||||
|
constructor(options) { seen.runtimeOptions = options; }
|
||||||
|
}
|
||||||
|
class Controller {
|
||||||
|
constructor(options) {
|
||||||
|
seen.controllerOptions = options;
|
||||||
|
this.status = () => ({ totals: { configured: 0, connected: 0 } });
|
||||||
|
this.initialize = async () => this.status();
|
||||||
|
}
|
||||||
|
async close() { seen.controllerClosed = true; }
|
||||||
|
}
|
||||||
|
const supervisor = {
|
||||||
|
ready: Promise.resolve(null),
|
||||||
|
start() { return this; },
|
||||||
|
async close() { seen.supervisorClosed = true; },
|
||||||
|
};
|
||||||
|
const credentials = {};
|
||||||
|
const production = await createProductionController({
|
||||||
|
credentials,
|
||||||
|
webServer: { port: 3080 },
|
||||||
|
logger: () => console,
|
||||||
|
}, { dataDir: directory }, {
|
||||||
|
ConfigStore,
|
||||||
|
DeviceAuth,
|
||||||
|
StateStore,
|
||||||
|
HarnessClient: Harness,
|
||||||
|
Controller,
|
||||||
|
Runtime,
|
||||||
|
createConnectionSupervisor: () => supervisor,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(seen.controllerOptions.credentials, credentials);
|
||||||
|
assert.equal(seen.harnessOptions.baseUrl.href, 'http://127.0.0.1:3080/');
|
||||||
|
assert.equal(seen.harnessOptions.autostart, false);
|
||||||
|
const runtime = await seen.controllerOptions.createRuntime({
|
||||||
|
botId: 'dt_abc',
|
||||||
|
config: { botId: 'dt_abc', clientId: 'dingabc' },
|
||||||
|
clientSecret: 'host-only-secret',
|
||||||
|
});
|
||||||
|
assert.ok(runtime instanceof Runtime);
|
||||||
|
assert.equal(seen.runtimeOptions.clientSecret, 'host-only-secret');
|
||||||
|
assert.match(seen.statePath, /dt_abc\/state\.json$/);
|
||||||
|
|
||||||
|
await production.close();
|
||||||
|
assert.equal(seen.supervisorClosed, true);
|
||||||
|
assert.equal(seen.controllerClosed, true);
|
||||||
|
assert.equal(seen.harnessStopped, true);
|
||||||
|
});
|
||||||
96
test/channels/dingtalk/rpc.test.mjs
Normal file
96
test/channels/dingtalk/rpc.test.mjs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DINGTALK_ENDPOINTS,
|
||||||
|
createDingtalkRpcHandler,
|
||||||
|
installDingtalkRpc,
|
||||||
|
} from '../../../plugin-src/host/channels/dingtalk/rpc.mjs';
|
||||||
|
|
||||||
|
function controller(overrides = {}) {
|
||||||
|
return {
|
||||||
|
status: async () => ({ bots: [] }),
|
||||||
|
startProvisioning: async () => ({
|
||||||
|
attemptId: 'attempt_1',
|
||||||
|
status: 'pending',
|
||||||
|
verificationUrl: 'https://open-dev.dingtalk.com/registration',
|
||||||
|
deviceCode: 'must-not-leak',
|
||||||
|
secretRef: 'must-not-leak',
|
||||||
|
}),
|
||||||
|
registrationStatus: async () => ({ attemptId: 'attempt_1', status: 'pending' }),
|
||||||
|
cancelProvisioning: async () => ({ attemptId: 'attempt_1', status: 'cancelled' }),
|
||||||
|
reconnectBot: async () => ({ bots: [] }),
|
||||||
|
deleteBot: async () => ({ bots: [] }),
|
||||||
|
approveSender: async () => ({ bots: [] }),
|
||||||
|
revokeSender: async () => ({ bots: [] }),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('RPC encodes QR on the Host and strips all credential material', async () => {
|
||||||
|
const handler = createDingtalkRpcHandler(controller(), {
|
||||||
|
encodeQr: async (url) => `data:image/png;base64,${Buffer.from(url).toString('base64')}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handler(DINGTALK_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.match(result.value.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||||
|
assert.equal('verificationUrl' in result.value, false);
|
||||||
|
assert.equal('deviceCode' in result.value, false);
|
||||||
|
assert.equal('secretRef' in result.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status re-encodes an active QR without exposing its authorization URL', async () => {
|
||||||
|
const handler = createDingtalkRpcHandler(controller({
|
||||||
|
status: async () => ({
|
||||||
|
bots: [],
|
||||||
|
provisioning: {
|
||||||
|
attemptId: 'attempt_1',
|
||||||
|
status: 'pending',
|
||||||
|
verificationUrl: 'https://open-dev.dingtalk.com/registration',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}), {
|
||||||
|
encodeQr: async () => 'data:image/png;base64,AAAA',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handler(DINGTALK_ENDPOINTS.status, {});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.value.provisioning.qrCodeDataUrl, 'data:image/png;base64,AAAA');
|
||||||
|
assert.equal('verificationUrl' in result.value.provisioning, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RPC validates mutating requests before invoking the controller', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const handler = createDingtalkRpcHandler(controller({
|
||||||
|
approveSender: async () => { calls += 1; },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rejected = await handler(DINGTALK_ENDPOINTS.approveSender, {
|
||||||
|
botId: 'dt_abc', requestId: 'request_1', confirm: false,
|
||||||
|
});
|
||||||
|
assert.equal(rejected.ok, false);
|
||||||
|
assert.equal(rejected.error.code, 'bad-request');
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RPC is registered for loopback clients only', () => {
|
||||||
|
const registrations = [];
|
||||||
|
const dispose = () => {};
|
||||||
|
const ctx = {
|
||||||
|
connection: {
|
||||||
|
rpc: {
|
||||||
|
handle: (...args) => {
|
||||||
|
registrations.push(args);
|
||||||
|
return dispose;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(installDingtalkRpc(ctx, controller()), dispose);
|
||||||
|
assert.equal(registrations[0][0], '/dingtalk');
|
||||||
|
assert.deepEqual(registrations[0][2], { authority: 'loopback' });
|
||||||
|
});
|
||||||
83
test/channels/dingtalk/state-store.test.mjs
Normal file
83
test/channels/dingtalk/state-store.test.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { DingtalkStateStore } from '../../../src/channels/dingtalk/state-store.mjs';
|
||||||
|
|
||||||
|
test('state store persists sessions, dedupe IDs, and host-only pending sender records atomically', async (t) => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'dsh-dingtalk-state-'));
|
||||||
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const path = join(directory, 'bot.json');
|
||||||
|
let currentTime = '2026-08-15T01:00:00.000Z';
|
||||||
|
const store = await new DingtalkStateStore(path, {
|
||||||
|
idFactory: () => 'fixed-id',
|
||||||
|
now: () => currentTime,
|
||||||
|
}).load();
|
||||||
|
|
||||||
|
await store.setSession('p2p:staff-one', 'session-one');
|
||||||
|
await store.markSeen('message-one');
|
||||||
|
const first = await store.recordPendingSender({
|
||||||
|
staffId: 'staff-one',
|
||||||
|
displayName: '小明',
|
||||||
|
sessionWebhook: 'https://oapi.dingtalk.com/reply?secret=must-not-persist',
|
||||||
|
});
|
||||||
|
currentTime = '2026-08-15T01:05:00.000Z';
|
||||||
|
const updated = await store.recordPendingSender({ staffId: 'staff-one', displayName: '小明新名字' });
|
||||||
|
|
||||||
|
assert.equal(store.sessionFor('p2p:staff-one'), 'session-one');
|
||||||
|
assert.equal(store.hasSeen('message-one'), true);
|
||||||
|
assert.equal(first.requestId, 'ding_sender_fixed-id');
|
||||||
|
assert.equal(updated.requestId, first.requestId);
|
||||||
|
assert.equal(updated.requestedAt, '2026-08-15T01:00:00.000Z');
|
||||||
|
assert.equal(updated.lastSeenAt, '2026-08-15T01:05:00.000Z');
|
||||||
|
assert.deepEqual(store.pendingSender(first.requestId), updated);
|
||||||
|
|
||||||
|
const storedText = await readFile(path, 'utf8');
|
||||||
|
assert.doesNotMatch(storedText, /sessionWebhook|must-not-persist/);
|
||||||
|
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||||
|
|
||||||
|
const reloaded = await new DingtalkStateStore(path).load();
|
||||||
|
assert.deepEqual(reloaded.pendingSenders(), [updated]);
|
||||||
|
assert.equal(await reloaded.removePendingSenderByStaffId('staff-one'), true);
|
||||||
|
assert.deepEqual(reloaded.pendingSenders(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state store keeps only normalized fields from an existing pending-sender document', async (t) => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'dsh-dingtalk-state-normalize-'));
|
||||||
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const path = join(directory, 'bot.json');
|
||||||
|
const { writeFile } = await import('node:fs/promises');
|
||||||
|
await writeFile(path, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
sessions: { valid: 'session', invalid: 3 },
|
||||||
|
seenMessageIds: ['one', 'one', null],
|
||||||
|
pendingSenders: {
|
||||||
|
request: {
|
||||||
|
requestId: 'request',
|
||||||
|
staffId: 'staff',
|
||||||
|
nick: '昵称',
|
||||||
|
requestedAt: '2026-08-15T01:00:00.000Z',
|
||||||
|
lastSeenAt: '2026-08-15T01:02:00.000Z',
|
||||||
|
sessionWebhook: 'https://oapi.dingtalk.com/reply?secret=discard',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const store = await new DingtalkStateStore(path).load();
|
||||||
|
assert.deepEqual(store.snapshot(), {
|
||||||
|
version: 1,
|
||||||
|
sessions: { valid: 'session' },
|
||||||
|
seenMessageIds: ['one'],
|
||||||
|
pendingSenders: {
|
||||||
|
request: {
|
||||||
|
requestId: 'request',
|
||||||
|
staffId: 'staff',
|
||||||
|
displayName: '昵称',
|
||||||
|
requestedAt: '2026-08-15T01:00:00.000Z',
|
||||||
|
lastSeenAt: '2026-08-15T01:02:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
240
test/channels/feishu/bridge.test.mjs
Normal file
240
test/channels/feishu/bridge.test.mjs
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||||
|
|
||||||
|
function event(messageId, text) {
|
||||||
|
return {
|
||||||
|
sender: { sender_type: 'user', sender_id: { open_id: 'ou_user' } },
|
||||||
|
message: {
|
||||||
|
message_id: messageId,
|
||||||
|
message_type: 'text',
|
||||||
|
chat_type: 'p2p',
|
||||||
|
chat_id: 'oc_chat',
|
||||||
|
content: JSON.stringify({ text }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bridge maps a Feishu conversation to a persistent Harness session and replies', async () => {
|
||||||
|
const sent = [];
|
||||||
|
const reactions = [];
|
||||||
|
const removedReactions = [];
|
||||||
|
const streamed = [];
|
||||||
|
const sessions = new Map();
|
||||||
|
const seen = new Set();
|
||||||
|
const asked = [];
|
||||||
|
const client = {
|
||||||
|
im: { v1: { message: { create: async (request) => {
|
||||||
|
sent.push(request);
|
||||||
|
return { code: 0 };
|
||||||
|
} } } },
|
||||||
|
};
|
||||||
|
const channel = {
|
||||||
|
addReaction: async (messageId, emojiType) => {
|
||||||
|
reactions.push({ messageId, emojiType });
|
||||||
|
return `reaction-${emojiType}`;
|
||||||
|
},
|
||||||
|
removeReaction: async (messageId, reactionId) => {
|
||||||
|
removedReactions.push({ messageId, reactionId });
|
||||||
|
},
|
||||||
|
stream: async (chatId, input, options) => {
|
||||||
|
const updates = [];
|
||||||
|
await input.markdown({
|
||||||
|
setContent: async (content) => updates.push(content),
|
||||||
|
});
|
||||||
|
streamed.push({ chatId, options, updates });
|
||||||
|
return { messageId: 'om_reply' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harness = {
|
||||||
|
ensureRunning: async () => true,
|
||||||
|
sessionExists: async (sessionId) => sessionId === 'session-test',
|
||||||
|
createSession: async () => 'session-test',
|
||||||
|
ask: async (sessionId, text, options) => {
|
||||||
|
asked.push({ sessionId, text });
|
||||||
|
await options.onUpdate({ type: 'text', text: 'Harness' });
|
||||||
|
return 'Harness reply';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const state = {
|
||||||
|
hasSeen: (id) => seen.has(id),
|
||||||
|
markSeen: async (id) => seen.add(id),
|
||||||
|
sessionFor: (key) => sessions.get(key) ?? null,
|
||||||
|
setSession: async (key, sessionId) => sessions.set(key, sessionId),
|
||||||
|
clearSession: async (key) => sessions.delete(key),
|
||||||
|
};
|
||||||
|
const status = {
|
||||||
|
messagesReceived: 0,
|
||||||
|
messagesReplied: 0,
|
||||||
|
messagesRejected: 0,
|
||||||
|
lastMessageAt: null,
|
||||||
|
lastReplyAt: null,
|
||||||
|
lastRejectedAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
const bridge = new FeishuHarnessBridge({
|
||||||
|
client,
|
||||||
|
channel,
|
||||||
|
harness,
|
||||||
|
state,
|
||||||
|
status,
|
||||||
|
allowedSenderOpenIds: new Set(['ou_user']),
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.accept(event('om_1', '你好'));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
|
||||||
|
assert.equal(sessions.get('p2p:ou_user'), 'session-test');
|
||||||
|
assert.deepEqual(asked, [{ sessionId: 'session-test', text: '你好' }]);
|
||||||
|
assert.deepEqual(streamed, [{
|
||||||
|
chatId: 'oc_chat',
|
||||||
|
options: { replyTo: 'om_1' },
|
||||||
|
updates: ['Harness', 'Harness reply'],
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(reactions, [
|
||||||
|
{ messageId: 'om_1', emojiType: 'OnIt' },
|
||||||
|
{ messageId: 'om_1', emojiType: 'DONE' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(removedReactions, [
|
||||||
|
{ messageId: 'om_1', reactionId: 'reaction-OnIt' },
|
||||||
|
]);
|
||||||
|
assert.equal(sent.length, 0);
|
||||||
|
assert.equal(status.messagesReceived, 1);
|
||||||
|
assert.equal(status.messagesReplied, 1);
|
||||||
|
assert.equal(status.streamResponses, 1);
|
||||||
|
|
||||||
|
bridge.accept(event('om_1', '重复消息'));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
assert.equal(asked.length, 1);
|
||||||
|
|
||||||
|
bridge.accept({
|
||||||
|
...event('om_2', '越权消息'),
|
||||||
|
sender: { sender_type: 'user', sender_id: { open_id: 'ou_other' } },
|
||||||
|
});
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
assert.equal(asked.length, 1);
|
||||||
|
assert.equal(status.messagesRejected, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reaction failures do not block streaming replies', async () => {
|
||||||
|
const seen = new Set();
|
||||||
|
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||||||
|
const bridge = new FeishuHarnessBridge({
|
||||||
|
client: {},
|
||||||
|
channel: {
|
||||||
|
addReaction: async () => { throw new Error('reaction unavailable'); },
|
||||||
|
stream: async (_chatId, input) => input.markdown({ setContent: async () => undefined }),
|
||||||
|
},
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async (_sessionId, _text, options) => {
|
||||||
|
await options.onUpdate({ type: 'tool', name: 'web_search' });
|
||||||
|
return '天气结果';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
hasSeen: (id) => seen.has(id),
|
||||||
|
markSeen: async (id) => seen.add(id),
|
||||||
|
sessionFor: () => 'session-existing',
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
allowedSenderOpenIds: new Set(['ou_user']),
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.accept(event('om_reaction_failure', '深圳天气'));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
|
||||||
|
assert.equal(status.messagesReplied, 1);
|
||||||
|
assert.equal(status.reactionErrors, 2);
|
||||||
|
assert.equal(status.streamResponses, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stream finalization failure falls back to text without repeating the prompt', async () => {
|
||||||
|
const seen = new Set();
|
||||||
|
const sent = [];
|
||||||
|
const finalReactions = [];
|
||||||
|
let askCount = 0;
|
||||||
|
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||||||
|
const bridge = new FeishuHarnessBridge({
|
||||||
|
client: {
|
||||||
|
im: { v1: { message: { create: async (request) => {
|
||||||
|
sent.push(JSON.parse(request.data.content).text);
|
||||||
|
return { code: 0 };
|
||||||
|
} } } },
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
addReaction: async (_messageId, emojiType) => {
|
||||||
|
finalReactions.push(emojiType);
|
||||||
|
return `reaction-${emojiType}`;
|
||||||
|
},
|
||||||
|
removeReaction: async () => undefined,
|
||||||
|
stream: async (_chatId, input) => {
|
||||||
|
await input.markdown({ setContent: async () => undefined });
|
||||||
|
throw new Error('card finalization failed');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async () => {
|
||||||
|
askCount += 1;
|
||||||
|
return '已经生成的最终回答';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
hasSeen: (id) => seen.has(id),
|
||||||
|
markSeen: async (id) => seen.add(id),
|
||||||
|
sessionFor: () => 'session-existing',
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
allowedSenderOpenIds: new Set(['ou_user']),
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.accept(event('om_stream_finalize_failure', '不要重复提交'));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
|
||||||
|
assert.equal(askCount, 1);
|
||||||
|
assert.deepEqual(sent, ['已经生成的最终回答']);
|
||||||
|
assert.deepEqual(finalReactions, ['OnIt', 'DONE']);
|
||||||
|
assert.equal(status.messagesReplied, 1);
|
||||||
|
assert.equal(status.streamFallbacks, 1);
|
||||||
|
assert.equal(status.streamErrors, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bridge does not expose internal error details in a Feishu failure reply', async () => {
|
||||||
|
const sent = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
|
||||||
|
const bridge = new FeishuHarnessBridge({
|
||||||
|
client: {
|
||||||
|
im: { v1: { message: { create: async (request) => {
|
||||||
|
sent.push(JSON.parse(request.data.content).text);
|
||||||
|
return { code: 0 };
|
||||||
|
} } } },
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
addReaction: async (_messageId, emojiType) => `reaction-${emojiType}`,
|
||||||
|
removeReaction: async () => undefined,
|
||||||
|
},
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async () => {
|
||||||
|
throw new Error('secret-shaped-internal-detail /private/path');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
hasSeen: (id) => seen.has(id),
|
||||||
|
markSeen: async (id) => seen.add(id),
|
||||||
|
sessionFor: () => 'session-existing',
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
allowedSenderOpenIds: new Set(['ou_user']),
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.accept(event('om_internal_failure', '触发错误'));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.match(sent[0], /处理失败,请稍后重试/);
|
||||||
|
assert.doesNotMatch(sent[0], /secret-shaped-internal-detail|private\/path/);
|
||||||
|
assert.equal(status.lastError, 'secret-shaped-internal-detail /private/path');
|
||||||
|
});
|
||||||
194
test/channels/feishu/client-api.test.mjs
Normal file
194
test/channels/feishu/client-api.test.mjs
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FEISHU_ENDPOINTS,
|
||||||
|
normalizeBotsSnapshot,
|
||||||
|
normalizeConnectionSnapshot,
|
||||||
|
normalizePollResult,
|
||||||
|
normalizeProvisioning,
|
||||||
|
presentError,
|
||||||
|
reconnectBot,
|
||||||
|
retryConnection,
|
||||||
|
screenFromSnapshot,
|
||||||
|
unwrapRpcResult,
|
||||||
|
} from '../../../plugin-src/client/channels/feishu/api.js';
|
||||||
|
|
||||||
|
test('multi-bot endpoints are bot-scoped and keep legacy operations separate', () => {
|
||||||
|
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
||||||
|
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
||||||
|
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
||||||
|
assert.equal(FEISHU_ENDPOINTS.testConnection, 'connection.test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client normalizes multiple independent bots and derives authoritative totals', () => {
|
||||||
|
const snapshot = normalizeBotsSnapshot({
|
||||||
|
schemaVersion: 2,
|
||||||
|
revision: 9,
|
||||||
|
totals: { configured: 99, connected: 99 },
|
||||||
|
bots: [
|
||||||
|
{
|
||||||
|
botId: 'bot-a',
|
||||||
|
state: 'connected',
|
||||||
|
connected: true,
|
||||||
|
configured: true,
|
||||||
|
bot: {
|
||||||
|
name: '销售助手',
|
||||||
|
appIdMasked: 'cli_aaaa••••1111',
|
||||||
|
domain: 'feishu',
|
||||||
|
clientSecret: 'must-not-leak',
|
||||||
|
},
|
||||||
|
health: { status: 'healthy', summary: '长连接运行正常' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
botId: 'bot-b',
|
||||||
|
// A stale state label must not make an offline bot appear connected.
|
||||||
|
state: 'connected',
|
||||||
|
connected: false,
|
||||||
|
configured: true,
|
||||||
|
bot: { name: '研发助手', domain: 'lark' },
|
||||||
|
health: { status: 'offline', summary: '等待重连' },
|
||||||
|
error: { code: 'connection_failed', message: '连接失败' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(snapshot.schemaVersion, 2);
|
||||||
|
assert.equal(snapshot.revision, 9);
|
||||||
|
assert.deepEqual(snapshot.totals, { configured: 2, connected: 1 });
|
||||||
|
assert.equal(snapshot.bots[0].state, 'connected');
|
||||||
|
assert.equal(snapshot.bots[1].state, 'connecting');
|
||||||
|
assert.equal(snapshot.bots[1].bot.domain, 'lark');
|
||||||
|
assert.equal(snapshot.bots[1].error.message, '连接失败');
|
||||||
|
assert.equal('clientSecret' in snapshot.bots[0].bot, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-bot snapshot rejects entries without an opaque botId', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeBotsSnapshot({ schemaVersion: 2, bots: [{ connected: true }] }),
|
||||||
|
/botId/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provision polling preserves the newly connected botId', () => {
|
||||||
|
assert.deepEqual(normalizePollResult({
|
||||||
|
status: 'connected',
|
||||||
|
botId: 'bot-new',
|
||||||
|
}), {
|
||||||
|
status: 'connected',
|
||||||
|
botId: 'bot-new',
|
||||||
|
message: undefined,
|
||||||
|
connection: undefined,
|
||||||
|
provisioning: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconnect helper scopes the mutation to one bot before refreshing the list', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const status = { schemaVersion: 2, bots: [] };
|
||||||
|
const value = await reconnectBot(async (endpoint, payload) => {
|
||||||
|
calls.push({ endpoint, payload });
|
||||||
|
return endpoint === 'connection.status' ? status : { accepted: true };
|
||||||
|
}, 'bot-a');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{ endpoint: 'bot.reconnect', payload: { botId: 'bot-a' } },
|
||||||
|
{ endpoint: 'connection.status', payload: {} },
|
||||||
|
]);
|
||||||
|
assert.equal(value, status);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client only shows connected when the Host confirms connected=true', () => {
|
||||||
|
assert.equal(normalizeConnectionSnapshot({
|
||||||
|
state: 'connected',
|
||||||
|
connected: false,
|
||||||
|
}).state, 'connecting');
|
||||||
|
assert.equal(normalizeConnectionSnapshot({
|
||||||
|
state: 'connecting',
|
||||||
|
connected: true,
|
||||||
|
}).state, 'connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client accepts a Host-rendered QR code without exposing credentials', () => {
|
||||||
|
const provisioning = normalizeProvisioning({
|
||||||
|
attemptId: '7',
|
||||||
|
verificationUrl: 'https://accounts.feishu.cn/device',
|
||||||
|
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||||
|
expiresAt: 100_000,
|
||||||
|
pollIntervalMs: 1_800,
|
||||||
|
}, 1_000);
|
||||||
|
|
||||||
|
assert.equal(provisioning.attemptId, '7');
|
||||||
|
assert.equal(provisioning.qrCodeDataUrl, 'data:image/png;base64,AAAA');
|
||||||
|
assert.equal('clientSecret' in provisioning, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client unwraps RpcResult and redacts credential-shaped error text', () => {
|
||||||
|
assert.deepEqual(unwrapRpcResult({ ok: true, value: { connected: true } }), {
|
||||||
|
connected: true,
|
||||||
|
});
|
||||||
|
assert.throws(
|
||||||
|
() => unwrapRpcResult({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'internal', message: 'failed' },
|
||||||
|
}),
|
||||||
|
/failed/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
presentError(new Error('app_secret=do-not-show token=also-hidden')).message,
|
||||||
|
/do-not-show|also-hidden/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configured offline and startup errors never become the create screen', () => {
|
||||||
|
const offline = normalizeConnectionSnapshot({
|
||||||
|
state: 'disconnected',
|
||||||
|
connected: false,
|
||||||
|
configured: true,
|
||||||
|
bot: { name: '北汇星河助手' },
|
||||||
|
health: { status: 'offline', summary: '长连接已断开' },
|
||||||
|
});
|
||||||
|
assert.equal(offline.configured, true);
|
||||||
|
assert.equal(screenFromSnapshot(offline).phase, 'offline');
|
||||||
|
|
||||||
|
const failed = screenFromSnapshot(normalizeConnectionSnapshot({
|
||||||
|
state: 'error',
|
||||||
|
connected: false,
|
||||||
|
configured: true,
|
||||||
|
error: { message: '启动失败' },
|
||||||
|
}));
|
||||||
|
assert.equal(failed.phase, 'error');
|
||||||
|
assert.equal(failed.retry, 'test');
|
||||||
|
assert.equal(failed.configured, true);
|
||||||
|
|
||||||
|
const failedBeforeConfiguration = screenFromSnapshot(normalizeConnectionSnapshot({
|
||||||
|
state: 'error',
|
||||||
|
connected: false,
|
||||||
|
configured: false,
|
||||||
|
error: { message: '二维码已过期' },
|
||||||
|
}));
|
||||||
|
assert.equal(failedBeforeConfiguration.phase, 'error');
|
||||||
|
assert.equal(failedBeforeConfiguration.retry, 'begin');
|
||||||
|
|
||||||
|
const fresh = screenFromSnapshot(normalizeConnectionSnapshot({
|
||||||
|
state: 'disconnected',
|
||||||
|
connected: false,
|
||||||
|
configured: false,
|
||||||
|
}));
|
||||||
|
assert.deepEqual(fresh, { phase: 'disconnected', configured: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retry connection calls connection.test before authoritative connection.status', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const status = { state: 'connected', connected: true, configured: true };
|
||||||
|
const value = await retryConnection(async (endpoint, payload) => {
|
||||||
|
calls.push({ endpoint, payload });
|
||||||
|
return endpoint === 'connection.status' ? status : { accepted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{ endpoint: 'connection.test', payload: {} },
|
||||||
|
{ endpoint: 'connection.status', payload: {} },
|
||||||
|
]);
|
||||||
|
assert.equal(value, status);
|
||||||
|
});
|
||||||
50
test/channels/feishu/config.test.mjs
Normal file
50
test/channels/feishu/config.test.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { loadConfig } from '../../../src/channels/feishu/config.mjs';
|
||||||
|
|
||||||
|
const KEYS = [
|
||||||
|
'FEISHU_APP_ID',
|
||||||
|
'FEISHU_APP_SECRET',
|
||||||
|
'FEISHU_SECRET_SERVICE',
|
||||||
|
'FEISHU_ALLOWED_OPEN_IDS',
|
||||||
|
'HARNESS_WORKSPACE',
|
||||||
|
'HARNESS_AGENT_PRESET',
|
||||||
|
];
|
||||||
|
|
||||||
|
function withEnvironment(values, callback) {
|
||||||
|
const previous = Object.fromEntries(KEYS.map((key) => [key, process.env[key]]));
|
||||||
|
try {
|
||||||
|
for (const key of KEYS) delete process.env[key];
|
||||||
|
Object.assign(process.env, values);
|
||||||
|
return callback();
|
||||||
|
} finally {
|
||||||
|
for (const key of KEYS) {
|
||||||
|
if (previous[key] === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = previous[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loadConfig fails closed when the sender allowlist is empty', () => {
|
||||||
|
withEnvironment({
|
||||||
|
FEISHU_APP_ID: 'cli_test',
|
||||||
|
FEISHU_APP_SECRET: 'test-secret',
|
||||||
|
HARNESS_WORKSPACE: '/tmp/test-workspace',
|
||||||
|
FEISHU_ALLOWED_OPEN_IDS: ' , ',
|
||||||
|
}, () => {
|
||||||
|
assert.throws(() => loadConfig(), /FEISHU_ALLOWED_OPEN_IDS/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadConfig parses a deduplicated sender allowlist and defaults to standard', () => {
|
||||||
|
withEnvironment({
|
||||||
|
FEISHU_APP_ID: 'cli_test',
|
||||||
|
FEISHU_APP_SECRET: 'test-secret',
|
||||||
|
HARNESS_WORKSPACE: '/tmp/test-workspace',
|
||||||
|
FEISHU_ALLOWED_OPEN_IDS: 'ou_one, ou_two,ou_one',
|
||||||
|
}, () => {
|
||||||
|
const config = loadConfig();
|
||||||
|
assert.equal(config.harnessAgentPreset, 'standard');
|
||||||
|
assert.deepEqual([...config.allowedSenderOpenIds], ['ou_one', 'ou_two']);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
test/channels/feishu/connection-supervisor.test.mjs
Normal file
98
test/channels/feishu/connection-supervisor.test.mjs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { ConnectionSupervisor } from '../../../plugin-src/host/channels/feishu/connection-supervisor.mjs';
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
function scheduler() {
|
||||||
|
const pending = [];
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
setTimeoutImpl(callback, delay) {
|
||||||
|
const handle = { callback, delay, cancelled: false, unref() {} };
|
||||||
|
pending.push(handle);
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
clearTimeoutImpl(handle) {
|
||||||
|
handle.cancelled = true;
|
||||||
|
},
|
||||||
|
async runNext() {
|
||||||
|
const handle = pending.shift();
|
||||||
|
assert.ok(handle, 'expected a scheduled supervisor pass');
|
||||||
|
assert.equal(handle.cancelled, false);
|
||||||
|
handle.callback();
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
return handle.delay;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('supervisor waits for the in-process Harness Host before initializing bots', async () => {
|
||||||
|
const timers = scheduler();
|
||||||
|
const warnings = [];
|
||||||
|
let healthChecks = 0;
|
||||||
|
let initializations = 0;
|
||||||
|
const controller = {
|
||||||
|
async initialize() { initializations += 1; },
|
||||||
|
status() { return { totals: { configured: 1, connected: 1 } }; },
|
||||||
|
};
|
||||||
|
const supervisor = new ConnectionSupervisor({
|
||||||
|
controller,
|
||||||
|
harness: {
|
||||||
|
async ensureRunning() {
|
||||||
|
healthChecks += 1;
|
||||||
|
if (healthChecks < 3) throw new Error('Host is still starting');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: { warn: (...args) => warnings.push(args) },
|
||||||
|
retryDelaysMs: [5, 10],
|
||||||
|
healthyIntervalMs: 100,
|
||||||
|
setTimeoutImpl: timers.setTimeoutImpl,
|
||||||
|
clearTimeoutImpl: timers.clearTimeoutImpl,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
assert.equal(await timers.runNext(), 0);
|
||||||
|
assert.equal(initializations, 0);
|
||||||
|
assert.equal(timers.pending[0].delay, 5);
|
||||||
|
await timers.runNext();
|
||||||
|
assert.equal(initializations, 0);
|
||||||
|
assert.equal(timers.pending[0].delay, 10);
|
||||||
|
await timers.runNext();
|
||||||
|
assert.equal(await supervisor.ready.then((status) => status.totals.connected), 1);
|
||||||
|
assert.equal(initializations, 1);
|
||||||
|
assert.equal(timers.pending[0].delay, 100);
|
||||||
|
assert.equal(warnings.length, 2);
|
||||||
|
|
||||||
|
await supervisor.close();
|
||||||
|
assert.equal(timers.pending[0].cancelled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supervisor retries an offline bot and leaves the recovered connection on the health interval', async () => {
|
||||||
|
const timers = scheduler();
|
||||||
|
let initializations = 0;
|
||||||
|
const controller = {
|
||||||
|
async initialize() { initializations += 1; },
|
||||||
|
status() {
|
||||||
|
return { totals: { configured: 1, connected: initializations >= 2 ? 1 : 0 } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const supervisor = new ConnectionSupervisor({
|
||||||
|
controller,
|
||||||
|
harness: { async ensureRunning() {} },
|
||||||
|
logger: { warn() {} },
|
||||||
|
retryDelaysMs: [7],
|
||||||
|
healthyIntervalMs: 101,
|
||||||
|
setTimeoutImpl: timers.setTimeoutImpl,
|
||||||
|
clearTimeoutImpl: timers.clearTimeoutImpl,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
await timers.runNext();
|
||||||
|
assert.equal(initializations, 1);
|
||||||
|
assert.equal(timers.pending[0].delay, 7);
|
||||||
|
await timers.runNext();
|
||||||
|
assert.equal(initializations, 2);
|
||||||
|
assert.equal(timers.pending[0].delay, 101);
|
||||||
|
|
||||||
|
await supervisor.close();
|
||||||
|
});
|
||||||
48
test/channels/feishu/feishu-app.test.mjs
Normal file
48
test/channels/feishu/feishu-app.test.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { verifyFeishuApp } from '../../../src/channels/feishu/feishu-app.mjs';
|
||||||
|
|
||||||
|
function response(body, ok = true, status = 200) {
|
||||||
|
return { ok, status, async json() { return body; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('verifyFeishuApp validates credentials and returns a safe bot identity', async () => {
|
||||||
|
const requests = [];
|
||||||
|
const result = await verifyFeishuApp({
|
||||||
|
appId: 'cli_test',
|
||||||
|
appSecret: 'never-return-this',
|
||||||
|
fetchImpl: async (url, options) => {
|
||||||
|
requests.push({ url: String(url), options });
|
||||||
|
if (requests.length === 1) {
|
||||||
|
return response({ code: 0, tenant_access_token: 'tenant-token' });
|
||||||
|
}
|
||||||
|
return response({
|
||||||
|
code: 0,
|
||||||
|
bot: { app_name: '北汇星河助手', open_id: 'ou_bot', activate_status: 1 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
appId: 'cli_test',
|
||||||
|
name: '北汇星河助手',
|
||||||
|
openId: 'ou_bot',
|
||||||
|
activated: 1,
|
||||||
|
});
|
||||||
|
assert.equal('appSecret' in result, false);
|
||||||
|
assert.match(requests[0].options.body, /never-return-this/);
|
||||||
|
assert.equal(requests[1].options.headers.authorization, 'Bearer tenant-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyFeishuApp rejects invalid credentials before reading bot info', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
await assert.rejects(verifyFeishuApp({
|
||||||
|
appId: 'cli_bad',
|
||||||
|
appSecret: 'bad',
|
||||||
|
fetchImpl: async () => {
|
||||||
|
calls += 1;
|
||||||
|
return response({ code: 10003, msg: 'invalid app secret' });
|
||||||
|
},
|
||||||
|
}), /invalid app secret/);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
131
test/channels/feishu/feishu-channel.test.mjs
Normal file
131
test/channels/feishu/feishu-channel.test.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { VerifiedFeishuChannel } from '../../../src/channels/feishu/feishu-channel.mjs';
|
||||||
|
|
||||||
|
function fakeClient(overrides = {}) {
|
||||||
|
const calls = {
|
||||||
|
replies: [],
|
||||||
|
updates: [],
|
||||||
|
settings: [],
|
||||||
|
recalls: [],
|
||||||
|
reactionsAdded: [],
|
||||||
|
reactionsRemoved: [],
|
||||||
|
};
|
||||||
|
const client = {
|
||||||
|
cardkit: { v1: {
|
||||||
|
card: {
|
||||||
|
create: async () => ({ code: 0, data: { card_id: 'card-test' } }),
|
||||||
|
settings: async (request) => {
|
||||||
|
calls.settings.push(request);
|
||||||
|
return { code: 0 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cardElement: {
|
||||||
|
content: async (request) => {
|
||||||
|
calls.updates.push(request);
|
||||||
|
return { code: 0 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} },
|
||||||
|
im: { v1: {
|
||||||
|
message: {
|
||||||
|
reply: async (request) => {
|
||||||
|
calls.replies.push(request);
|
||||||
|
return { code: 0, data: { message_id: 'om-stream' } };
|
||||||
|
},
|
||||||
|
create: async () => ({ code: 0, data: { message_id: 'om-stream' } }),
|
||||||
|
delete: async (request) => {
|
||||||
|
calls.recalls.push(request);
|
||||||
|
return { code: 0 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messageReaction: {
|
||||||
|
create: async (request) => {
|
||||||
|
calls.reactionsAdded.push(request);
|
||||||
|
return { code: 0, data: { reaction_id: 'reaction-test' } };
|
||||||
|
},
|
||||||
|
delete: async (request) => {
|
||||||
|
calls.reactionsRemoved.push(request);
|
||||||
|
return { code: 0 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (overrides.updateContent) client.cardkit.v1.cardElement.content = overrides.updateContent;
|
||||||
|
if (overrides.finishCard) client.cardkit.v1.card.settings = overrides.finishCard;
|
||||||
|
return { client, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('VerifiedFeishuChannel streams content and verifies terminal settings', async () => {
|
||||||
|
const { client, calls } = fakeClient();
|
||||||
|
const channel = new VerifiedFeishuChannel({ client, initialText: '正在思考…' });
|
||||||
|
|
||||||
|
const result = await channel.stream('oc_chat', {
|
||||||
|
markdown: async (controller) => {
|
||||||
|
await controller.setContent('第一段');
|
||||||
|
await controller.setContent('第一段和第二段');
|
||||||
|
},
|
||||||
|
}, { replyTo: 'om_user' });
|
||||||
|
|
||||||
|
assert.deepEqual(result, { messageId: 'om-stream' });
|
||||||
|
assert.equal(calls.replies[0].path.message_id, 'om_user');
|
||||||
|
assert.deepEqual(calls.updates.map((call) => ({
|
||||||
|
content: call.data.content,
|
||||||
|
sequence: call.data.sequence,
|
||||||
|
})), [
|
||||||
|
{ content: '第一段', sequence: 1 },
|
||||||
|
{ content: '第一段和第二段', sequence: 2 },
|
||||||
|
]);
|
||||||
|
assert.equal(calls.settings[0].data.sequence, 3);
|
||||||
|
assert.deepEqual(JSON.parse(calls.settings[0].data.settings), {
|
||||||
|
config: {
|
||||||
|
streaming_mode: false,
|
||||||
|
summary: { content: '第一段和第二段' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(calls.recalls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VerifiedFeishuChannel rejects failed updates and recalls the partial card', async () => {
|
||||||
|
const { client, calls } = fakeClient({
|
||||||
|
updateContent: async () => ({ code: 230099, msg: 'element update failed' }),
|
||||||
|
});
|
||||||
|
const channel = new VerifiedFeishuChannel({ client });
|
||||||
|
|
||||||
|
await assert.rejects(channel.stream('oc_chat', {
|
||||||
|
markdown: async (controller) => controller.setContent('最终回答'),
|
||||||
|
}, { replyTo: 'om_user' }), /cardElement\.content failed/);
|
||||||
|
|
||||||
|
assert.deepEqual(calls.recalls, [{ path: { message_id: 'om-stream' } }]);
|
||||||
|
assert.equal(calls.settings.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VerifiedFeishuChannel rejects failed finalization and recalls the card', async () => {
|
||||||
|
const { client, calls } = fakeClient({
|
||||||
|
finishCard: async (request) => {
|
||||||
|
calls.settings.push(request);
|
||||||
|
return { code: 230099, msg: 'card finalization failed' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const channel = new VerifiedFeishuChannel({ client });
|
||||||
|
|
||||||
|
await assert.rejects(channel.stream('oc_chat', {
|
||||||
|
markdown: async (controller) => controller.setContent('已经生成的回答'),
|
||||||
|
}, { replyTo: 'om_user' }), /card\.settings failed/);
|
||||||
|
|
||||||
|
assert.deepEqual(calls.recalls, [{ path: { message_id: 'om-stream' } }]);
|
||||||
|
assert.equal(calls.settings.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VerifiedFeishuChannel checks reaction API results', async () => {
|
||||||
|
const { client, calls } = fakeClient();
|
||||||
|
const channel = new VerifiedFeishuChannel({ client });
|
||||||
|
|
||||||
|
const reactionId = await channel.addReaction('om_user', 'OnIt');
|
||||||
|
await channel.removeReaction('om_user', reactionId);
|
||||||
|
|
||||||
|
assert.equal(reactionId, 'reaction-test');
|
||||||
|
assert.equal(calls.reactionsAdded[0].data.reaction_type.emoji_type, 'OnIt');
|
||||||
|
assert.equal(calls.reactionsRemoved[0].path.reaction_id, 'reaction-test');
|
||||||
|
});
|
||||||
120
test/channels/feishu/feishu-runtime.test.mjs
Normal file
120
test/channels/feishu/feishu-runtime.test.mjs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { FeishuRuntime } from '../../../src/channels/feishu/feishu-runtime.mjs';
|
||||||
|
|
||||||
|
class FakeClient {}
|
||||||
|
|
||||||
|
class FakeDispatcher {
|
||||||
|
register(handlers) {
|
||||||
|
this.handlers = handlers;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeWSClient {
|
||||||
|
static instances = [];
|
||||||
|
|
||||||
|
constructor(options) {
|
||||||
|
this.options = options;
|
||||||
|
this.state = 'idle';
|
||||||
|
FakeWSClient.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.state = 'connecting';
|
||||||
|
}
|
||||||
|
|
||||||
|
becomeReady() {
|
||||||
|
this.state = 'connected';
|
||||||
|
this.options.onReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectionStatus() {
|
||||||
|
return { state: this.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.state = 'closed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeLark() {
|
||||||
|
FakeWSClient.instances.length = 0;
|
||||||
|
return {
|
||||||
|
Domain: { Feishu: 'feishu-domain', Lark: 'lark-domain' },
|
||||||
|
LoggerLevel: { info: 'info' },
|
||||||
|
Client: FakeClient,
|
||||||
|
EventDispatcher: FakeDispatcher,
|
||||||
|
WSClient: FakeWSClient,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
|
||||||
|
let harnessChecks = 0;
|
||||||
|
const runtime = new FeishuRuntime({
|
||||||
|
lark: fakeLark(),
|
||||||
|
appId: 'cli_test',
|
||||||
|
appSecret: 'secret',
|
||||||
|
ownerOpenId: 'ou_owner',
|
||||||
|
harness: {
|
||||||
|
async ensureRunning() { harnessChecks += 1; },
|
||||||
|
},
|
||||||
|
state: { hasSeen: () => false },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(runtime.status.ready, false);
|
||||||
|
let settled = false;
|
||||||
|
const starting = runtime.start().then((value) => {
|
||||||
|
settled = true;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(settled, false);
|
||||||
|
assert.equal(runtime.status.feishuLongConnectionState, 'connecting');
|
||||||
|
FakeWSClient.instances[0].becomeReady();
|
||||||
|
const status = await starting;
|
||||||
|
assert.equal(harnessChecks, 1);
|
||||||
|
assert.equal(status.ready, true);
|
||||||
|
assert.equal(status.feishuLongConnectionState, 'connected');
|
||||||
|
assert.equal(status.harnessReachable, true);
|
||||||
|
|
||||||
|
const stopped = await runtime.stop();
|
||||||
|
assert.equal(stopped.ready, false);
|
||||||
|
assert.equal(stopped.feishuLongConnectionState, 'idle');
|
||||||
|
assert.equal(FakeWSClient.instances[0].state, 'closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FeishuRuntime fails closed when the initial WebSocket handshake times out', async () => {
|
||||||
|
const runtime = new FeishuRuntime({
|
||||||
|
lark: fakeLark(),
|
||||||
|
appId: 'cli_test',
|
||||||
|
appSecret: 'secret',
|
||||||
|
ownerOpenId: 'ou_owner',
|
||||||
|
harness: { async ensureRunning() {} },
|
||||||
|
state: { hasSeen: () => false },
|
||||||
|
connectTimeoutMs: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(runtime.start(), /handshake timed out/);
|
||||||
|
assert.equal(runtime.status.ready, false);
|
||||||
|
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
||||||
|
assert.equal(FakeWSClient.instances[0].state, 'closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FeishuRuntime fails closed when Harness is unavailable', async () => {
|
||||||
|
const runtime = new FeishuRuntime({
|
||||||
|
lark: fakeLark(),
|
||||||
|
appId: 'cli_test',
|
||||||
|
appSecret: 'secret',
|
||||||
|
ownerOpenId: 'ou_owner',
|
||||||
|
harness: {
|
||||||
|
async ensureRunning() { throw new Error('Harness unavailable'); },
|
||||||
|
},
|
||||||
|
state: { hasSeen: () => false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(runtime.start(), /Harness unavailable/);
|
||||||
|
assert.equal(runtime.status.ready, false);
|
||||||
|
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
||||||
|
assert.equal(runtime.status.lastError, 'Harness unavailable');
|
||||||
|
});
|
||||||
132
test/channels/feishu/harness-client.test.mjs
Normal file
132
test/channels/feishu/harness-client.test.mjs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
HarnessClient,
|
||||||
|
HarnessReplyTracker,
|
||||||
|
} from '../../../src/channels/feishu/harness-client.mjs';
|
||||||
|
|
||||||
|
test('HarnessClient reads the nested workspace.create response used by DSH rc.6', async (t) => {
|
||||||
|
const methods = [];
|
||||||
|
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||||
|
const request = JSON.parse(options.body);
|
||||||
|
methods.push(request.method);
|
||||||
|
const value = request.method === 'workspace.list'
|
||||||
|
? { items: [], archivedSessionIds: [] }
|
||||||
|
: {
|
||||||
|
workspace: {
|
||||||
|
workspaceId: 'workspace-new',
|
||||||
|
path: '/tmp/dsh-feishu-workspace',
|
||||||
|
},
|
||||||
|
created: true,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return {
|
||||||
|
type: 'server-response',
|
||||||
|
rpcId: request.rpcId,
|
||||||
|
result: { ok: true, value },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new HarnessClient({
|
||||||
|
baseUrl: 'http://127.0.0.1:3080',
|
||||||
|
workspace: '/tmp/dsh-feishu-workspace',
|
||||||
|
agentPreset: 'standard',
|
||||||
|
autostart: false,
|
||||||
|
dshBin: 'dsh',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(await client.workspaceId(), 'workspace-new');
|
||||||
|
assert.deepEqual(methods, ['workspace.list', 'workspace.create']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HarnessReplyTracker correlates the prompt and emits only answer text', () => {
|
||||||
|
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-1', afterSeq: 10 });
|
||||||
|
|
||||||
|
assert.equal(tracker.consume([
|
||||||
|
{ event: { type: 'turn/start', seq: 11, data: { turn: 4 } } },
|
||||||
|
{ event: {
|
||||||
|
type: 'user/message',
|
||||||
|
seq: 12,
|
||||||
|
data: { source: { rpcId: 'someone-else' } },
|
||||||
|
} },
|
||||||
|
{ event: {
|
||||||
|
type: 'assistant/chunk',
|
||||||
|
seq: 13,
|
||||||
|
data: { turn: 4, step: 1, chunk: { type: 'text-delta', index: 0, text: '忽略' } },
|
||||||
|
} },
|
||||||
|
{ event: { type: 'turn/end', seq: 14, data: { turn: 4, reason: { kind: 'completed' } } } },
|
||||||
|
]), null);
|
||||||
|
assert.equal(tracker.finished, false);
|
||||||
|
|
||||||
|
const first = tracker.consume([
|
||||||
|
{ event: { type: 'turn/start', seq: 15, data: { turn: 5 } } },
|
||||||
|
{ event: {
|
||||||
|
type: 'user/message',
|
||||||
|
seq: 16,
|
||||||
|
data: { source: { rpcId: 'prompt-1' } },
|
||||||
|
} },
|
||||||
|
{ event: {
|
||||||
|
type: 'assistant/chunk',
|
||||||
|
seq: 17,
|
||||||
|
data: { turn: 5, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: '不能泄露' } },
|
||||||
|
} },
|
||||||
|
{ event: {
|
||||||
|
type: 'assistant/chunk',
|
||||||
|
seq: 18,
|
||||||
|
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '深圳' } },
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(first, { type: 'text', text: '深圳' });
|
||||||
|
|
||||||
|
const second = tracker.consume([
|
||||||
|
{ event: {
|
||||||
|
type: 'assistant/chunk',
|
||||||
|
seq: 18,
|
||||||
|
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '重复' } },
|
||||||
|
} },
|
||||||
|
{ event: {
|
||||||
|
type: 'assistant/chunk',
|
||||||
|
seq: 19,
|
||||||
|
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '明天有雨' } },
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(second, { type: 'text', text: '深圳明天有雨' });
|
||||||
|
|
||||||
|
const final = tracker.consume([
|
||||||
|
{ event: {
|
||||||
|
type: 'assistant/message',
|
||||||
|
seq: 20,
|
||||||
|
data: {
|
||||||
|
turn: 5,
|
||||||
|
step: 1,
|
||||||
|
message: { content: [
|
||||||
|
{ type: 'reasoning', text: '仍然不能泄露' },
|
||||||
|
{ type: 'text', text: '深圳明天有阵雨。' },
|
||||||
|
] },
|
||||||
|
},
|
||||||
|
} },
|
||||||
|
{ event: { type: 'turn/end', seq: 21, data: { turn: 5, reason: { kind: 'completed' } } } },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(final, { type: 'text', text: '深圳明天有阵雨。' });
|
||||||
|
assert.equal(tracker.finished, true);
|
||||||
|
assert.equal(tracker.answer, '深圳明天有阵雨。');
|
||||||
|
assert.deepEqual(tracker.reason, { kind: 'completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HarnessReplyTracker emits tool progress without exposing tool results', () => {
|
||||||
|
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-tool' });
|
||||||
|
const update = tracker.consume([
|
||||||
|
{ type: 'turn/start', seq: 1, data: { turn: 1 } },
|
||||||
|
{ type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-tool' } } },
|
||||||
|
{ type: 'tool/call', seq: 3, data: { turn: 1, step: 1, name: 'web_search' } },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(update, { type: 'tool', name: 'web_search' });
|
||||||
|
|
||||||
|
assert.deepEqual(tracker.consume([
|
||||||
|
{ type: 'tool/result', seq: 4, data: { turn: 1, step: 1, secret: 'not rendered' } },
|
||||||
|
]), { type: 'status', text: '正在整理结果…' });
|
||||||
|
});
|
||||||
50
test/channels/feishu/message-utils.test.mjs
Normal file
50
test/channels/feishu/message-utils.test.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
conversationKey,
|
||||||
|
extractText,
|
||||||
|
isAllowedSender,
|
||||||
|
isBotSender,
|
||||||
|
splitText,
|
||||||
|
} from '../../../src/channels/feishu/message-utils.mjs';
|
||||||
|
|
||||||
|
test('extractText removes bot mentions', () => {
|
||||||
|
const event = {
|
||||||
|
message: {
|
||||||
|
message_type: 'text',
|
||||||
|
content: JSON.stringify({ text: '@_user_1 你好' }),
|
||||||
|
mentions: [{ key: '@_user_1' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(extractText(event), '你好');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('conversationKey isolates p2p users and groups', () => {
|
||||||
|
assert.equal(conversationKey({
|
||||||
|
sender: { sender_id: { open_id: 'ou_test' } },
|
||||||
|
message: { chat_type: 'p2p', chat_id: 'oc_private' },
|
||||||
|
}), 'p2p:ou_test');
|
||||||
|
assert.equal(conversationKey({
|
||||||
|
sender: { sender_id: { open_id: 'ou_test' } },
|
||||||
|
message: { chat_type: 'group', chat_id: 'oc_group' },
|
||||||
|
}), 'group:oc_group');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splitText preserves all text', () => {
|
||||||
|
const input = `${'a'.repeat(12)}\n${'b'.repeat(12)}`;
|
||||||
|
const chunks = splitText(input, 15);
|
||||||
|
assert.equal(chunks.join('\n'), input);
|
||||||
|
assert.ok(chunks.every((chunk) => chunk.length <= 15));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isBotSender rejects bot loops', () => {
|
||||||
|
assert.equal(isBotSender({ sender: { sender_type: 'bot' } }), true);
|
||||||
|
assert.equal(isBotSender({ sender: { sender_type: 'user' } }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isAllowedSender enforces an open-id allowlist', () => {
|
||||||
|
const event = { sender: { sender_id: { open_id: 'ou_allowed' } } };
|
||||||
|
assert.equal(isAllowedSender(event, new Set()), false);
|
||||||
|
assert.equal(isAllowedSender(event, new Set(['ou_allowed'])), true);
|
||||||
|
assert.equal(isAllowedSender(event, new Set(['ou_other'])), false);
|
||||||
|
});
|
||||||
485
test/channels/feishu/multi-bot-controller.test.mjs
Normal file
485
test/channels/feishu/multi-bot-controller.test.mjs
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { MultiBotDshFeishuController } from '../../../src/channels/feishu/multi-bot-controller.mjs';
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
async function waitFor(predicate, timeoutMs = 1000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() >= deadline) throw new Error('condition timed out');
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryConfigStore {
|
||||||
|
constructor(bots = []) {
|
||||||
|
this.bots = structuredClone(bots);
|
||||||
|
}
|
||||||
|
list() { return structuredClone(this.bots); }
|
||||||
|
getBot(id) {
|
||||||
|
const bot = this.bots.find((candidate) => candidate.id === id);
|
||||||
|
return bot ? structuredClone(bot) : null;
|
||||||
|
}
|
||||||
|
async saveBot(bot) {
|
||||||
|
const index = this.bots.findIndex((candidate) => candidate.id === bot.id);
|
||||||
|
if (index === -1) this.bots.push(structuredClone(bot));
|
||||||
|
else this.bots[index] = structuredClone(bot);
|
||||||
|
return structuredClone(bot);
|
||||||
|
}
|
||||||
|
async removeBot(id) {
|
||||||
|
const index = this.bots.findIndex((candidate) => candidate.id === id);
|
||||||
|
return index === -1 ? null : this.bots.splice(index, 1)[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bot(id, suffix = id) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
appId: `cli_${suffix}`,
|
||||||
|
secretRef: `DSH_FEISHU_APP_SECRET_${suffix.toUpperCase()}`,
|
||||||
|
ownerOpenIds: [`ou_${suffix}`],
|
||||||
|
domain: 'feishu',
|
||||||
|
botName: `机器人 ${suffix}`,
|
||||||
|
botOpenId: `ou_bot_${suffix}`,
|
||||||
|
activated: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture({
|
||||||
|
bots = [],
|
||||||
|
secrets = {},
|
||||||
|
createBotIds = [],
|
||||||
|
failResolveRefs = new Set(),
|
||||||
|
failUnsetRefs = new Set(),
|
||||||
|
runtimeStart,
|
||||||
|
deleteState,
|
||||||
|
} = {}) {
|
||||||
|
const configStore = new MemoryConfigStore(bots);
|
||||||
|
const values = new Map(Object.entries(secrets));
|
||||||
|
const unsetCalls = [];
|
||||||
|
const registrationRuns = [];
|
||||||
|
const runtimes = new Map();
|
||||||
|
let registrationSequence = 0;
|
||||||
|
let botSequence = 0;
|
||||||
|
const controller = new MultiBotDshFeishuController({
|
||||||
|
registerApp(options) {
|
||||||
|
let resolve;
|
||||||
|
let reject;
|
||||||
|
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||||
|
registrationRuns.push({ options, resolve, reject });
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
verifyApp: async ({ appId }) => ({
|
||||||
|
name: `已验证 ${appId}`,
|
||||||
|
openId: `ou_bot_${appId}`,
|
||||||
|
activated: 1,
|
||||||
|
}),
|
||||||
|
credentials: {
|
||||||
|
async resolve(ref) {
|
||||||
|
if (failResolveRefs.has(ref)) throw new Error('credential provider unavailable');
|
||||||
|
return values.has(ref) ? { value: values.get(ref), source: 'file' } : undefined;
|
||||||
|
},
|
||||||
|
async set(ref, value) { values.set(ref, value); },
|
||||||
|
async unset(ref) {
|
||||||
|
unsetCalls.push(ref);
|
||||||
|
if (failUnsetRefs.has(ref)) throw new Error('credential provider is read-only');
|
||||||
|
values.delete(ref);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configStore,
|
||||||
|
createRuntime: async ({ botId, config, appSecret }) => {
|
||||||
|
const status = {
|
||||||
|
ready: false,
|
||||||
|
feishuLongConnectionState: 'idle',
|
||||||
|
harnessReachable: false,
|
||||||
|
};
|
||||||
|
const runtime = {
|
||||||
|
botId,
|
||||||
|
config: structuredClone(config),
|
||||||
|
appSecret,
|
||||||
|
starts: 0,
|
||||||
|
stops: 0,
|
||||||
|
get status() { return structuredClone(status); },
|
||||||
|
async start() {
|
||||||
|
runtime.starts += 1;
|
||||||
|
if (runtimeStart) await runtimeStart({ botId, runtime });
|
||||||
|
status.ready = true;
|
||||||
|
status.feishuLongConnectionState = 'connected';
|
||||||
|
status.harnessReachable = true;
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
runtime.stops += 1;
|
||||||
|
status.ready = false;
|
||||||
|
status.feishuLongConnectionState = 'idle';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const history = runtimes.get(botId) ?? [];
|
||||||
|
history.push(runtime);
|
||||||
|
runtimes.set(botId, history);
|
||||||
|
return runtime;
|
||||||
|
},
|
||||||
|
deleteState: deleteState ?? (async () => {}),
|
||||||
|
createBotId() {
|
||||||
|
const id = createBotIds[botSequence] ?? `bot_generated_${++botSequence}`;
|
||||||
|
botSequence += createBotIds.length > 0 ? 1 : 0;
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
createRegistrationId: () => `reg_${++registrationSequence}`,
|
||||||
|
});
|
||||||
|
return { controller, configStore, values, unsetCalls, registrationRuns, runtimes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeScan(fx, result) {
|
||||||
|
const started = fx.controller.startRegistration();
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length > 0);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: `https://accounts.feishu.cn/${attemptId}`, expireIn: 60 });
|
||||||
|
run.resolve(result);
|
||||||
|
await waitFor(() => ['succeeded', 'error'].includes(
|
||||||
|
fx.controller.registrationStatus(attemptId).registration.state,
|
||||||
|
));
|
||||||
|
return fx.controller.registrationStatus(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('initialization isolates failures and starts every bot with available credentials', async () => {
|
||||||
|
const missing = bot('bot_missing', 'missing');
|
||||||
|
const healthy = bot('bot_healthy', 'healthy');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [missing, healthy],
|
||||||
|
secrets: { [healthy.secretRef]: 'healthy-secret' },
|
||||||
|
failResolveRefs: new Set([missing.secretRef]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const status = fx.controller.status();
|
||||||
|
|
||||||
|
assert.equal(status.totals.configured, 2);
|
||||||
|
assert.equal(status.totals.connected, 1);
|
||||||
|
assert.equal(status.bots.find((entry) => entry.botId === missing.id).phase, 'error');
|
||||||
|
assert.equal(status.bots.find((entry) => entry.botId === healthy.id).connected, true);
|
||||||
|
assert.equal(fx.runtimes.has(missing.id), false);
|
||||||
|
assert.equal(fx.runtimes.get(healthy.id).length, 1);
|
||||||
|
assert.doesNotMatch(JSON.stringify(status), /healthy-secret|DSH_FEISHU_APP_SECRET|ou_healthy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated initialization never restarts an already healthy bot', async () => {
|
||||||
|
const healthy = bot('bot_healthy', 'healthy');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [healthy],
|
||||||
|
secrets: { [healthy.secretRef]: 'healthy-secret' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await fx.controller.initialize();
|
||||||
|
await fx.controller.initialize();
|
||||||
|
|
||||||
|
assert.equal(fx.controller.status().totals.connected, 1);
|
||||||
|
assert.equal(fx.runtimes.get(healthy.id).length, 1);
|
||||||
|
assert.equal(fx.runtimes.get(healthy.id)[0].starts, 1);
|
||||||
|
assert.equal(fx.runtimes.get(healthy.id)[0].stops, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple scans create independent bots, credential refs and runtimes', async () => {
|
||||||
|
const fx = fixture({ createBotIds: ['bot_alpha', 'bot_beta'] });
|
||||||
|
const alpha = completeScan(fx, {
|
||||||
|
client_id: 'cli_alpha', client_secret: 'secret-alpha',
|
||||||
|
user_info: { open_id: 'ou_alpha', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
const beta = completeScan(fx, {
|
||||||
|
client_id: 'cli_beta', client_secret: 'secret-beta',
|
||||||
|
user_info: { open_id: 'ou_beta', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
const [alphaStatus, betaStatus] = await Promise.all([alpha, beta]);
|
||||||
|
|
||||||
|
assert.equal(alphaStatus.registration.state, 'succeeded');
|
||||||
|
assert.equal(betaStatus.registration.state, 'succeeded');
|
||||||
|
assert.equal(fx.configStore.list().length, 2);
|
||||||
|
const [first, second] = fx.configStore.list();
|
||||||
|
assert.notEqual(first.id, second.id);
|
||||||
|
assert.notEqual(first.secretRef, second.secretRef);
|
||||||
|
assert.match(first.secretRef, /^[A-Za-z_][A-Za-z0-9_]*$/);
|
||||||
|
assert.equal(fx.runtimes.get(first.id).length, 1);
|
||||||
|
assert.equal(fx.runtimes.get(second.id).length, 1);
|
||||||
|
assert.equal(fx.controller.status().totals.connected, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scanning an existing app is idempotent and reuses its bot and secret ref', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'old-secret' },
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const completed = await completeScan(fx, {
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'rotated-secret',
|
||||||
|
user_info: { open_id: 'ou_second_owner', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(completed.registration.state, 'succeeded');
|
||||||
|
assert.equal(completed.registration.botId, existing.id);
|
||||||
|
assert.equal(fx.configStore.list().length, 1);
|
||||||
|
assert.equal(fx.configStore.list()[0].secretRef, existing.secretRef);
|
||||||
|
assert.deepEqual(fx.configStore.list()[0].ownerOpenIds.sort(), ['ou_existing', 'ou_second_owner']);
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
|
||||||
|
assert.equal(fx.runtimes.get(existing.id).length, 2);
|
||||||
|
assert.equal(fx.runtimes.get(existing.id)[0].stops, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting one bot clears only its secret and leaves the other runtime online', async () => {
|
||||||
|
const alpha = bot('bot_alpha', 'alpha');
|
||||||
|
const beta = bot('bot_beta', 'beta');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [alpha, beta],
|
||||||
|
secrets: { [alpha.secretRef]: 'secret-a', [beta.secretRef]: 'secret-b' },
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const alphaRuntime = fx.runtimes.get(alpha.id)[0];
|
||||||
|
const betaRuntime = fx.runtimes.get(beta.id)[0];
|
||||||
|
|
||||||
|
const status = await fx.controller.deleteBot(alpha.id);
|
||||||
|
|
||||||
|
assert.deepEqual(fx.unsetCalls, [alpha.secretRef]);
|
||||||
|
assert.equal(fx.values.has(alpha.secretRef), false);
|
||||||
|
assert.equal(fx.values.get(beta.secretRef), 'secret-b');
|
||||||
|
assert.equal(alphaRuntime.stops, 1);
|
||||||
|
assert.equal(betaRuntime.stops, 0);
|
||||||
|
assert.equal(status.totals.configured, 1);
|
||||||
|
assert.equal(status.totals.connected, 1);
|
||||||
|
assert.equal(status.bots[0].botId, beta.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling a terminal attempt is a no-op and cannot roll back a newer same-app scan', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'original-secret' },
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const first = await completeScan(fx, {
|
||||||
|
client_id: existing.appId, client_secret: 'first-secret',
|
||||||
|
user_info: { open_id: 'ou_first', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
const oldAttemptId = first.registration.attempt;
|
||||||
|
const second = await completeScan(fx, {
|
||||||
|
client_id: existing.appId, client_secret: 'newest-secret',
|
||||||
|
user_info: { open_id: 'ou_second', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelled = await fx.controller.cancelRegistration(oldAttemptId);
|
||||||
|
|
||||||
|
assert.equal(cancelled.registration.state, 'succeeded');
|
||||||
|
assert.equal(second.registration.botId, existing.id);
|
||||||
|
assert.equal(fx.configStore.list().length, 1);
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'newest-secret');
|
||||||
|
assert.equal(fx.controller.status().bots[0].connected, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credential removal failure is retriable and cannot affect another bot', async () => {
|
||||||
|
const alpha = bot('bot_alpha', 'alpha');
|
||||||
|
const beta = bot('bot_beta', 'beta');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [alpha, beta],
|
||||||
|
secrets: { [alpha.secretRef]: 'secret-a', [beta.secretRef]: 'secret-b' },
|
||||||
|
failUnsetRefs: new Set([alpha.secretRef]),
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const betaRuntime = fx.runtimes.get(beta.id)[0];
|
||||||
|
|
||||||
|
await assert.rejects(fx.controller.deleteBot(alpha.id), /remove the Feishu credential/);
|
||||||
|
|
||||||
|
assert.equal(fx.configStore.list().length, 2);
|
||||||
|
assert.equal(fx.values.get(alpha.secretRef), 'secret-a');
|
||||||
|
assert.equal(fx.values.get(beta.secretRef), 'secret-b');
|
||||||
|
assert.equal(betaRuntime.stops, 0);
|
||||||
|
assert.equal(fx.controller.status().bots.find((entry) => entry.botId === alpha.id).error.code,
|
||||||
|
'credential_removal_failed');
|
||||||
|
|
||||||
|
// Deletion intent is durable. Even though the immutable secret still
|
||||||
|
// exists, a fresh controller must not resurrect this bot on restart.
|
||||||
|
const restarted = fixture({
|
||||||
|
bots: fx.configStore.list(),
|
||||||
|
secrets: Object.fromEntries(fx.values),
|
||||||
|
});
|
||||||
|
await restarted.controller.initialize();
|
||||||
|
assert.equal(restarted.runtimes.has(alpha.id), false);
|
||||||
|
assert.equal(restarted.controller.status().bots.find((entry) => entry.botId === alpha.id).error.code,
|
||||||
|
'deletion_pending');
|
||||||
|
assert.equal(restarted.controller.status().bots.find((entry) => entry.botId === beta.id).connected, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one bot activation failure does not stop a healthy existing bot', async () => {
|
||||||
|
const healthy = bot('bot_healthy', 'healthy');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [healthy],
|
||||||
|
secrets: { [healthy.secretRef]: 'healthy-secret' },
|
||||||
|
createBotIds: ['bot_failure'],
|
||||||
|
runtimeStart: async ({ botId }) => {
|
||||||
|
if (botId === 'bot_failure') throw new Error('new bot handshake failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const healthyRuntime = fx.runtimes.get(healthy.id)[0];
|
||||||
|
const result = await completeScan(fx, {
|
||||||
|
client_id: 'cli_failure', client_secret: 'failure-secret',
|
||||||
|
user_info: { open_id: 'ou_failure', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.registration.state, 'error');
|
||||||
|
assert.equal(healthyRuntime.stops, 0);
|
||||||
|
assert.equal(fx.controller.status().bots.find((entry) => entry.botId === healthy.id).connected, true);
|
||||||
|
assert.equal(fx.controller.status().bots.find((entry) => entry.botId === 'bot_failure').phase, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close during credential activation cannot leave a late runtime or bot behind', async () => {
|
||||||
|
let releaseStart;
|
||||||
|
const startGate = new Promise((resolve) => { releaseStart = resolve; });
|
||||||
|
const fx = fixture({
|
||||||
|
createBotIds: ['bot_closing'],
|
||||||
|
runtimeStart: async ({ botId }) => {
|
||||||
|
if (botId === 'bot_closing') await startGate;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const started = fx.controller.startRegistration();
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/closing', expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: 'cli_closing', client_secret: 'closing-secret',
|
||||||
|
user_info: { open_id: 'ou_closing', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'saving');
|
||||||
|
|
||||||
|
const closing = fx.controller.close();
|
||||||
|
releaseStart();
|
||||||
|
await closing;
|
||||||
|
|
||||||
|
assert.equal(fx.configStore.list().length, 0);
|
||||||
|
assert.equal(fx.values.size, 0);
|
||||||
|
assert.equal(fx.controller.status().totals.connected, 0);
|
||||||
|
assert.equal(fx.runtimes.get('bot_closing').at(-1).stops > 0, true);
|
||||||
|
assert.throws(() => fx.controller.startRegistration(), /closed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed repeat-scan restores the previously healthy config, secret and runtime', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
runtimeStart: async ({ runtime }) => {
|
||||||
|
if (runtime.appSecret === 'bad-rotated-secret') throw new Error('new handshake failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const result = await completeScan(fx, {
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'bad-rotated-secret',
|
||||||
|
user_info: { open_id: 'ou_new_owner', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.registration.state, 'error');
|
||||||
|
assert.deepEqual(fx.configStore.list()[0], existing);
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
|
||||||
|
assert.equal(fx.controller.status().bots[0].connected, true);
|
||||||
|
assert.equal(fx.runtimes.get(existing.id).length, 3);
|
||||||
|
assert.equal(fx.runtimes.get(existing.id).at(-1).appSecret, 'stable-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state cleanup failure keeps the bot visible and retryable with no live credential', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
deleteState: async () => { throw new Error('state file is busy'); },
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
|
||||||
|
await assert.rejects(fx.controller.deleteBot(existing.id), /session state/);
|
||||||
|
|
||||||
|
assert.equal(fx.configStore.list().length, 1);
|
||||||
|
assert.equal(fx.values.has(existing.secretRef), false);
|
||||||
|
const visible = fx.controller.status().bots[0];
|
||||||
|
assert.equal(visible.botId, existing.id);
|
||||||
|
assert.equal(visible.error.code, 'state_cleanup_failed');
|
||||||
|
assert.equal(visible.connected, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling after a successful replacement start restores the old runtime exactly once', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
let releaseReplacement;
|
||||||
|
const replacementGate = new Promise((resolve) => { releaseReplacement = resolve; });
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
runtimeStart: async ({ runtime }) => {
|
||||||
|
if (runtime.appSecret === 'replacement-secret') await replacementGate;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const started = fx.controller.startRegistration();
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/replacement', expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'replacement-secret',
|
||||||
|
user_info: { open_id: 'ou_replacement', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
await waitFor(() => fx.runtimes.get(existing.id)?.length === 2);
|
||||||
|
|
||||||
|
const cancelling = fx.controller.cancelRegistration(attemptId);
|
||||||
|
releaseReplacement();
|
||||||
|
await cancelling;
|
||||||
|
|
||||||
|
const history = fx.runtimes.get(existing.id);
|
||||||
|
assert.equal(history.length, 3);
|
||||||
|
assert.equal(history[1].appSecret, 'replacement-secret');
|
||||||
|
assert.equal(history[1].stops, 1);
|
||||||
|
assert.equal(history[2].appSecret, 'stable-secret');
|
||||||
|
assert.equal(history[2].starts, 1);
|
||||||
|
assert.deepEqual(fx.configStore.list()[0], existing);
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cancelled replacement whose start rejects still restores the old runtime', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
let releaseReplacement;
|
||||||
|
const replacementGate = new Promise((resolve) => { releaseReplacement = resolve; });
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
runtimeStart: async ({ runtime }) => {
|
||||||
|
if (runtime.appSecret === 'replacement-secret') {
|
||||||
|
await replacementGate;
|
||||||
|
throw new Error('replacement handshake rejected');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const started = fx.controller.startRegistration();
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/replacement-reject', expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'replacement-secret',
|
||||||
|
user_info: { open_id: 'ou_replacement', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
await waitFor(() => fx.runtimes.get(existing.id)?.length === 2);
|
||||||
|
|
||||||
|
const cancelling = fx.controller.cancelRegistration(attemptId);
|
||||||
|
releaseReplacement();
|
||||||
|
await cancelling;
|
||||||
|
|
||||||
|
const history = fx.runtimes.get(existing.id);
|
||||||
|
assert.equal(history.length, 3);
|
||||||
|
assert.equal(history.at(-1).appSecret, 'stable-secret');
|
||||||
|
assert.equal(history.at(-1).starts, 1);
|
||||||
|
assert.deepEqual(fx.configStore.list()[0], existing);
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
|
||||||
|
assert.equal(fx.controller.status().bots[0].connected, true);
|
||||||
|
});
|
||||||
101
test/channels/feishu/plugin-config-store.test.mjs
Normal file
101
test/channels/feishu/plugin-config-store.test.mjs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdir, mkdtemp, readFile, stat, unlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { PluginConfigStore } from '../../../src/channels/feishu/plugin-config-store.mjs';
|
||||||
|
|
||||||
|
test('PluginConfigStore persists non-secret onboarding facts', async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-'));
|
||||||
|
const path = join(dir, 'nested', 'config.json');
|
||||||
|
const store = await new PluginConfigStore(path).load();
|
||||||
|
assert.equal(store.get(), null);
|
||||||
|
|
||||||
|
await store.save({
|
||||||
|
appId: 'cli_test',
|
||||||
|
ownerOpenId: 'ou_owner',
|
||||||
|
domain: 'feishu',
|
||||||
|
botName: '北汇星河助手',
|
||||||
|
appSecret: 'must-not-be-written',
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = await readFile(path, 'utf8');
|
||||||
|
assert.doesNotMatch(raw, /must-not-be-written/);
|
||||||
|
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||||
|
assert.equal((await new PluginConfigStore(path).load()).get().appId, 'cli_test');
|
||||||
|
|
||||||
|
await store.clear();
|
||||||
|
assert.equal(store.get(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PluginConfigStore stays unconfigured after a failed write and can retry', async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-retry-'));
|
||||||
|
const blockedParent = join(dir, 'blocked');
|
||||||
|
const path = join(blockedParent, 'config.json');
|
||||||
|
const store = await new PluginConfigStore(path).load();
|
||||||
|
await writeFile(blockedParent, 'not a directory');
|
||||||
|
const value = { appId: 'cli_retry', ownerOpenId: 'ou_retry', domain: 'feishu' };
|
||||||
|
|
||||||
|
await assert.rejects(store.save(value));
|
||||||
|
assert.equal(store.get(), null);
|
||||||
|
|
||||||
|
await unlink(blockedParent);
|
||||||
|
await mkdir(blockedParent);
|
||||||
|
await store.save(value);
|
||||||
|
assert.equal(store.get().appId, 'cli_retry');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PluginConfigStore migrates a v1 bot atomically without moving its credential', async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-migrate-'));
|
||||||
|
const path = join(dir, 'config.json');
|
||||||
|
await writeFile(path, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
appId: 'cli_legacy',
|
||||||
|
ownerOpenId: 'ou_legacy',
|
||||||
|
domain: 'feishu',
|
||||||
|
botName: '旧机器人',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const store = await new PluginConfigStore(path).load();
|
||||||
|
const migrated = JSON.parse(await readFile(path, 'utf8'));
|
||||||
|
|
||||||
|
assert.equal(migrated.version, 2);
|
||||||
|
assert.equal(migrated.bots.length, 1);
|
||||||
|
assert.match(migrated.bots[0].id, /^bot_[a-f0-9]{24}$/);
|
||||||
|
assert.equal(migrated.bots[0].secretRef, 'DSH_FEISHU_APP_SECRET');
|
||||||
|
assert.deepEqual(migrated.bots[0].ownerOpenIds, ['ou_legacy']);
|
||||||
|
assert.equal(store.get().ownerOpenId, 'ou_legacy');
|
||||||
|
assert.equal(store.list()[0].appId, 'cli_legacy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PluginConfigStore rejects an invalid or duplicate v2 document without dropping entries', async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-invalid-v2-'));
|
||||||
|
const invalidPath = join(dir, 'invalid.json');
|
||||||
|
await writeFile(invalidPath, JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
bots: [
|
||||||
|
{
|
||||||
|
id: 'bot_good', appId: 'cli_good', secretRef: 'DSH_FEISHU_APP_SECRET_GOOD',
|
||||||
|
ownerOpenIds: ['ou_good'], domain: 'feishu',
|
||||||
|
},
|
||||||
|
{ id: '../bad', appId: 'cli_bad', secretRef: 'not-a-credential-ref', ownerOpenIds: ['ou_bad'] },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
await assert.rejects(new PluginConfigStore(invalidPath).load(), /invalid bot entry/);
|
||||||
|
|
||||||
|
const duplicatePath = join(dir, 'duplicate.json');
|
||||||
|
await writeFile(duplicatePath, JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
bots: [
|
||||||
|
{
|
||||||
|
id: 'bot_one', appId: 'cli_same', secretRef: 'DSH_FEISHU_APP_SECRET_ONE',
|
||||||
|
ownerOpenIds: ['ou_one'], domain: 'feishu',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bot_two', appId: 'cli_same', secretRef: 'DSH_FEISHU_APP_SECRET_TWO',
|
||||||
|
ownerOpenIds: ['ou_two'], domain: 'feishu',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
await assert.rejects(new PluginConfigStore(duplicatePath).load(), /duplicate bot identities/);
|
||||||
|
});
|
||||||
139
test/channels/feishu/plugin-controller.test.mjs
Normal file
139
test/channels/feishu/plugin-controller.test.mjs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
DshFeishuController,
|
||||||
|
FEISHU_SECRET_REF,
|
||||||
|
} from '../../../src/channels/feishu/plugin-controller.mjs';
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
function fixture({ initialConfig = null, failConfigSave = false } = {}) {
|
||||||
|
let sdkOptions;
|
||||||
|
let config = initialConfig;
|
||||||
|
let storedSecret = null;
|
||||||
|
const runtimes = [];
|
||||||
|
const controller = new DshFeishuController({
|
||||||
|
registerApp: (options) => {
|
||||||
|
sdkOptions = options;
|
||||||
|
return new Promise((resolve) => { fixture.resolveRegistration = resolve; });
|
||||||
|
},
|
||||||
|
verifyApp: async () => ({ name: '北汇星河助手', openId: 'ou_bot', activated: 1 }),
|
||||||
|
credentials: {
|
||||||
|
async resolve(ref) {
|
||||||
|
assert.equal(ref, FEISHU_SECRET_REF);
|
||||||
|
return storedSecret ? { value: storedSecret, source: 'file' } : undefined;
|
||||||
|
},
|
||||||
|
async set(ref, value) {
|
||||||
|
assert.equal(ref, FEISHU_SECRET_REF);
|
||||||
|
storedSecret = value;
|
||||||
|
},
|
||||||
|
async unset(ref) {
|
||||||
|
assert.equal(ref, FEISHU_SECRET_REF);
|
||||||
|
storedSecret = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configStore: {
|
||||||
|
get: () => config ? structuredClone(config) : null,
|
||||||
|
async save(next) {
|
||||||
|
if (failConfigSave) throw new Error('config write failed');
|
||||||
|
config = structuredClone(next);
|
||||||
|
return structuredClone(config);
|
||||||
|
},
|
||||||
|
async clear() { config = null; },
|
||||||
|
},
|
||||||
|
createRuntime: async () => {
|
||||||
|
const status = {
|
||||||
|
ready: false,
|
||||||
|
feishuLongConnectionState: 'idle',
|
||||||
|
harnessReachable: false,
|
||||||
|
};
|
||||||
|
const runtime = {
|
||||||
|
get status() { return structuredClone(status); },
|
||||||
|
async start() {
|
||||||
|
status.ready = true;
|
||||||
|
status.feishuLongConnectionState = 'connected';
|
||||||
|
status.harnessReachable = true;
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
status.ready = false;
|
||||||
|
status.feishuLongConnectionState = 'idle';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runtimes.push(runtime);
|
||||||
|
return runtime;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
controller,
|
||||||
|
getSdkOptions: () => sdkOptions,
|
||||||
|
getConfig: () => config,
|
||||||
|
getSecret: () => storedSecret,
|
||||||
|
runtimes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('QR success stores the secret off-config and becomes immediately chat-ready', async () => {
|
||||||
|
const fx = fixture();
|
||||||
|
const start = fx.controller.startRegistration();
|
||||||
|
assert.equal(start.phase, 'registering');
|
||||||
|
await flush();
|
||||||
|
assert.equal(fx.getSdkOptions().createOnly, true);
|
||||||
|
assert.equal(fx.getSdkOptions().addons.preset, false);
|
||||||
|
assert.deepEqual(fx.getSdkOptions().addons.events.items.tenant, ['im.message.receive_v1']);
|
||||||
|
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
|
||||||
|
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
|
||||||
|
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('cardkit:card:write'));
|
||||||
|
fx.getSdkOptions().onQRCodeReady({ url: 'https://accounts.feishu.cn/qr', expireIn: 600 });
|
||||||
|
fixture.resolveRegistration({
|
||||||
|
client_id: 'cli_created',
|
||||||
|
client_secret: 'new-secret',
|
||||||
|
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const status = fx.controller.status();
|
||||||
|
assert.equal(status.phase, 'connected');
|
||||||
|
assert.equal(status.connected, true);
|
||||||
|
assert.equal(status.bot.name, '北汇星河助手');
|
||||||
|
assert.equal(fx.getSecret(), 'new-secret');
|
||||||
|
assert.equal(fx.getConfig().appSecret, undefined);
|
||||||
|
assert.doesNotMatch(JSON.stringify(status), /new-secret|client_secret/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect removes configuration and stops automatic reuse', async () => {
|
||||||
|
const fx = fixture();
|
||||||
|
fx.controller.startRegistration();
|
||||||
|
await flush();
|
||||||
|
fixture.resolveRegistration({
|
||||||
|
client_id: 'cli_created',
|
||||||
|
client_secret: 'new-secret',
|
||||||
|
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
await fx.controller.disconnect();
|
||||||
|
|
||||||
|
assert.equal(fx.controller.status().phase, 'unconfigured');
|
||||||
|
assert.equal(fx.getConfig(), null);
|
||||||
|
assert.equal(fx.getSecret(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credential is removed when non-secret configuration cannot be persisted', async () => {
|
||||||
|
const fx = fixture({ failConfigSave: true });
|
||||||
|
fx.controller.startRegistration();
|
||||||
|
await flush();
|
||||||
|
fixture.resolveRegistration({
|
||||||
|
client_id: 'cli_created',
|
||||||
|
client_secret: 'new-secret',
|
||||||
|
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
assert.equal(fx.getSecret(), null);
|
||||||
|
assert.equal(fx.getConfig(), null);
|
||||||
|
assert.equal(fx.controller.status().phase, 'error');
|
||||||
|
});
|
||||||
716
test/channels/feishu/plugin-host.test.mjs
Normal file
716
test/channels/feishu/plugin-host.test.mjs
Normal file
|
|
@ -0,0 +1,716 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
FEISHU_APP_ID_REF,
|
||||||
|
FEISHU_APP_SECRET_REF,
|
||||||
|
FEISHU_ENDPOINTS,
|
||||||
|
FEISHU_MULTI_ENDPOINTS,
|
||||||
|
apply,
|
||||||
|
createDshCredentialStore,
|
||||||
|
createProductionController,
|
||||||
|
createProvisioningBackedController,
|
||||||
|
} from '../../../plugin-src/host/channels/feishu/index.mjs';
|
||||||
|
|
||||||
|
const signal = () => new AbortController().signal;
|
||||||
|
|
||||||
|
function status(overrides = {}) {
|
||||||
|
return {
|
||||||
|
phase: 'unconfigured',
|
||||||
|
connected: false,
|
||||||
|
configured: false,
|
||||||
|
bot: null,
|
||||||
|
registration: { state: 'idle', attempt: 0, updatedAt: 100 },
|
||||||
|
connection: {
|
||||||
|
ready: false,
|
||||||
|
feishuLongConnectionState: 'idle',
|
||||||
|
harnessReachable: false,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpcFixture(controller) {
|
||||||
|
let registration;
|
||||||
|
let disposed = false;
|
||||||
|
const ctx = {
|
||||||
|
connection: {
|
||||||
|
rpc: {
|
||||||
|
handle(channel, handler, options) {
|
||||||
|
registration = { channel, handler, options };
|
||||||
|
return async () => { disposed = true; };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const dispose = await apply(ctx, { controller });
|
||||||
|
return {
|
||||||
|
dispose,
|
||||||
|
get registration() { return registration; },
|
||||||
|
get disposed() { return disposed; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Host plugin registers the real rc.6 Connection RPC shape as loopback-only', async () => {
|
||||||
|
const controller = {
|
||||||
|
status: async () => status(),
|
||||||
|
startRegistration: async () => status(),
|
||||||
|
cancelRegistration: async () => status(),
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
|
||||||
|
assert.equal(fx.registration.channel, '/feishu');
|
||||||
|
assert.deepEqual(fx.registration.options, { authority: 'loopback' });
|
||||||
|
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.value.state, 'disconnected');
|
||||||
|
assert.equal(result.value.connected, false);
|
||||||
|
assert.equal(result.value.configured, false);
|
||||||
|
assert.equal(result.value.bot.name, '飞书机器人');
|
||||||
|
assert.equal(result.value.health.status, 'offline');
|
||||||
|
assert.equal('registration' in result.value, false);
|
||||||
|
|
||||||
|
await fx.dispose();
|
||||||
|
assert.equal(fx.disposed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Host exposes configured offline as a redacted capability fact', async () => {
|
||||||
|
const secret = 'offline-secret-must-stay-host-only';
|
||||||
|
const controller = {
|
||||||
|
status: async () => status({
|
||||||
|
phase: 'disconnected',
|
||||||
|
configured: true,
|
||||||
|
bot: { name: '北汇星河助手', appSecret: secret },
|
||||||
|
credentials: { client_secret: secret },
|
||||||
|
connection: {
|
||||||
|
ready: false,
|
||||||
|
feishuLongConnectionState: 'failed',
|
||||||
|
harnessReachable: true,
|
||||||
|
token: secret,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
startRegistration: async () => status(),
|
||||||
|
cancelRegistration: async () => status(),
|
||||||
|
reconnect: async () => status({ configured: true }),
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.value.state, 'disconnected');
|
||||||
|
assert.equal(result.value.connected, false);
|
||||||
|
assert.equal(result.value.configured, true);
|
||||||
|
assert.equal(result.value.health.status, 'offline');
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), new RegExp(secret));
|
||||||
|
assert.equal('credentials' in result.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
||||||
|
const calls = [];
|
||||||
|
let current = status({
|
||||||
|
phase: 'registering',
|
||||||
|
registration: {
|
||||||
|
state: 'qr_ready',
|
||||||
|
attempt: 7,
|
||||||
|
updatedAt: 100,
|
||||||
|
qrCodeUrl: 'https://accounts.feishu.cn/device',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => current,
|
||||||
|
startRegistration: async () => { calls.push('begin'); return current; },
|
||||||
|
cancelRegistration: async () => {
|
||||||
|
calls.push('cancel');
|
||||||
|
current = status({ registration: { state: 'cancelled', attempt: 7, updatedAt: 200 } });
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
reconnect: async () => { calls.push('test'); return current; },
|
||||||
|
disconnect: async () => { calls.push('disconnect'); return status(); },
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
|
||||||
|
const begun = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.beginProvisioning,
|
||||||
|
{ locale: 'zh-CN' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(begun.ok, true);
|
||||||
|
assert.equal(begun.value.attemptId, '7');
|
||||||
|
assert.match(begun.value.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||||
|
|
||||||
|
const polled = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.pollProvisioning,
|
||||||
|
{ attemptId: '7' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(polled.ok, true);
|
||||||
|
assert.equal(polled.value.status, 'pending');
|
||||||
|
|
||||||
|
const tested = await fx.registration.handler(FEISHU_ENDPOINTS.testConnection, {}, signal());
|
||||||
|
assert.equal(tested.ok, true);
|
||||||
|
|
||||||
|
const cancelled = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.cancelProvisioning,
|
||||||
|
{ attemptId: '7' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(cancelled.ok, true);
|
||||||
|
assert.equal(cancelled.value.status, 'failed');
|
||||||
|
|
||||||
|
const disconnected = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.disconnect,
|
||||||
|
{ removeCredentials: true },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(disconnected.ok, true);
|
||||||
|
assert.deepEqual(calls, ['begin', 'test', 'cancel', 'disconnect']);
|
||||||
|
|
||||||
|
const attemptedSecret = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.beginProvisioning,
|
||||||
|
{ appSecret: 'must-not-cross-browser-boundary' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(attemptedSecret.ok, false);
|
||||||
|
assert.equal(attemptedSecret.error.code, 'bad-request');
|
||||||
|
assert.doesNotMatch(JSON.stringify(attemptedSecret), /must-not-cross-browser-boundary/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connection.test does not restart an already healthy long connection', async () => {
|
||||||
|
let reconnects = 0;
|
||||||
|
const healthy = status({
|
||||||
|
phase: 'connected',
|
||||||
|
connected: true,
|
||||||
|
configured: true,
|
||||||
|
connection: {
|
||||||
|
ready: true,
|
||||||
|
feishuLongConnectionState: 'connected',
|
||||||
|
harnessReachable: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => healthy,
|
||||||
|
startRegistration: async () => healthy,
|
||||||
|
cancelRegistration: async () => healthy,
|
||||||
|
reconnect: async () => { reconnects += 1; return healthy; },
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
|
||||||
|
const result = await fx.registration.handler(FEISHU_ENDPOINTS.testConnection, {}, signal());
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.value.connected, true);
|
||||||
|
assert.equal(reconnects, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provision.begin waits through starting until onQRCodeReady is observable', async () => {
|
||||||
|
let current = status({
|
||||||
|
phase: 'registering',
|
||||||
|
registration: { state: 'starting', attempt: 3, updatedAt: 100 },
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => current,
|
||||||
|
startRegistration: async () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
current = status({
|
||||||
|
phase: 'registering',
|
||||||
|
registration: {
|
||||||
|
state: 'qr_ready',
|
||||||
|
attempt: 3,
|
||||||
|
updatedAt: 110,
|
||||||
|
qrCodeUrl: 'https://accounts.feishu.cn/async-qr',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 5);
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
cancelRegistration: async () => status(),
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const begun = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.beginProvisioning,
|
||||||
|
{ locale: 'zh-CN' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(begun.ok, true);
|
||||||
|
assert.equal(begun.value.attemptId, '3');
|
||||||
|
assert.equal(begun.value.verificationUrl, 'https://accounts.feishu.cn/async-qr');
|
||||||
|
assert.match(begun.value.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling during credential activation tears down the eventual runtime', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const current = status({
|
||||||
|
phase: 'connecting',
|
||||||
|
configured: true,
|
||||||
|
registration: { state: 'saving', attempt: 11, updatedAt: 100 },
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => current,
|
||||||
|
startRegistration: async () => current,
|
||||||
|
cancelRegistration: async () => { calls.push('cancel-only'); return current; },
|
||||||
|
disconnect: async () => { calls.push('disconnect'); return status(); },
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const result = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.cancelProvisioning,
|
||||||
|
{ attemptId: '11' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(calls, ['disconnect']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status returns qrCodeDataUrl while dropping all credential-shaped fields', async () => {
|
||||||
|
const secret = 'sdk-super-secret-value';
|
||||||
|
const controller = {
|
||||||
|
status: async () => status({
|
||||||
|
phase: 'registering',
|
||||||
|
appSecret: secret,
|
||||||
|
credentials: { client_secret: secret },
|
||||||
|
registration: {
|
||||||
|
state: 'qr_ready',
|
||||||
|
attempt: 1,
|
||||||
|
qrCodeUrl: 'https://accounts.feishu.cn/device',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
client_secret: secret,
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
ready: true,
|
||||||
|
connected: false,
|
||||||
|
state: 'connecting',
|
||||||
|
token: secret,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
startRegistration: async () => status(),
|
||||||
|
cancelRegistration: async () => status(),
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.value.state, 'provisioning');
|
||||||
|
assert.equal(result.value.provisioning.verificationUrl, 'https://accounts.feishu.cn/device');
|
||||||
|
assert.match(result.value.provisioning.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /sdk-super-secret-value|client_secret|credentials|token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('polling a new QR stays pending when another bot is already connected', async () => {
|
||||||
|
const current = status({
|
||||||
|
schemaVersion: 2,
|
||||||
|
phase: 'registering',
|
||||||
|
connected: false,
|
||||||
|
configured: true,
|
||||||
|
registration: {
|
||||||
|
state: 'qr_ready',
|
||||||
|
attempt: 'reg_new_bot',
|
||||||
|
qrCodeUrl: 'https://accounts.feishu.cn/new-bot',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
bots: [{
|
||||||
|
botId: 'bot_existing',
|
||||||
|
phase: 'connected',
|
||||||
|
connected: true,
|
||||||
|
configured: true,
|
||||||
|
bot: { name: '现有机器人', appIdMasked: 'cli_old••••1234' },
|
||||||
|
connection: {
|
||||||
|
ready: true,
|
||||||
|
feishuLongConnectionState: 'connected',
|
||||||
|
harnessReachable: true,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => current,
|
||||||
|
registrationStatus: async () => current,
|
||||||
|
startRegistration: async () => current,
|
||||||
|
cancelRegistration: async () => current,
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const result = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.pollProvisioning,
|
||||||
|
{ attemptId: 'reg_new_bot' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.value.status, 'pending');
|
||||||
|
assert.equal('botId' in result.value, false);
|
||||||
|
assert.equal('connection' in result.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-bot RPC operations require a botId and never expose host-only fields', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const multi = status({
|
||||||
|
schemaVersion: 2,
|
||||||
|
revision: 9,
|
||||||
|
configured: true,
|
||||||
|
bots: [{
|
||||||
|
botId: 'bot_safe',
|
||||||
|
phase: 'connected',
|
||||||
|
connected: true,
|
||||||
|
configured: true,
|
||||||
|
secretRef: 'DSH_FEISHU_APP_SECRET_PRIVATE',
|
||||||
|
ownerOpenIds: ['ou_private'],
|
||||||
|
bot: {
|
||||||
|
name: '安全机器人',
|
||||||
|
appId: 'cli_raw_private',
|
||||||
|
appIdMasked: 'cli_safe••••1234',
|
||||||
|
domain: 'feishu',
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
ready: true,
|
||||||
|
feishuLongConnectionState: 'connected',
|
||||||
|
harnessReachable: true,
|
||||||
|
token: 'private-token',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => multi,
|
||||||
|
startRegistration: async () => multi,
|
||||||
|
cancelRegistration: async () => multi,
|
||||||
|
disconnect: async () => status(),
|
||||||
|
reconnectBot: async (botId) => { calls.push(['reconnect', botId]); return multi; },
|
||||||
|
disconnectBot: async (botId) => { calls.push(['disconnect', botId]); return multi; },
|
||||||
|
deleteBot: async (botId) => { calls.push(['delete', botId]); return status({ schemaVersion: 2, bots: [] }); },
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
|
||||||
|
for (const [endpoint, payload] of [
|
||||||
|
[FEISHU_MULTI_ENDPOINTS.reconnectBot, { botId: 'bot_safe' }],
|
||||||
|
[FEISHU_MULTI_ENDPOINTS.disconnectBot, { botId: 'bot_safe' }],
|
||||||
|
[FEISHU_MULTI_ENDPOINTS.deleteBot, { botId: 'bot_safe', confirm: true }],
|
||||||
|
]) {
|
||||||
|
const result = await fx.registration.handler(endpoint, payload, signal());
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /DSH_FEISHU_APP_SECRET|ou_private|cli_raw_private|private-token/);
|
||||||
|
}
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['reconnect', 'bot_safe'],
|
||||||
|
['disconnect', 'bot_safe'],
|
||||||
|
['delete', 'bot_safe'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const invalid = await fx.registration.handler(
|
||||||
|
FEISHU_MULTI_ENDPOINTS.deleteBot,
|
||||||
|
{ botId: '../private', confirm: true },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(invalid.ok, false);
|
||||||
|
assert.equal(invalid.error.code, 'bad-request');
|
||||||
|
assert.doesNotMatch(JSON.stringify(invalid), /\.\.\/private/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dependency failures and AbortSignal use valid RpcResult error branches', async () => {
|
||||||
|
const secret = 'should-never-be-reflected';
|
||||||
|
const controller = {
|
||||||
|
status: async () => { throw new Error(`SDK failed with ${secret}`); },
|
||||||
|
startRegistration: async () => status(),
|
||||||
|
cancelRegistration: async () => status(),
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const failure = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||||
|
assert.deepEqual(failure, {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'internal',
|
||||||
|
message: 'The Feishu integration operation failed.',
|
||||||
|
details: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.doesNotMatch(JSON.stringify(failure), new RegExp(secret));
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
abort.abort();
|
||||||
|
const cancelled = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, abort.signal);
|
||||||
|
assert.equal(cancelled.ok, false);
|
||||||
|
assert.equal(cancelled.error.code, 'cancelled');
|
||||||
|
|
||||||
|
const unknown = await fx.registration.handler('credentials.read', {}, signal());
|
||||||
|
assert.equal(unknown.ok, false);
|
||||||
|
assert.equal(unknown.error.code, 'bad-request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provisioning callback stores credentials and connects before connected is visible', async () => {
|
||||||
|
const secret = 'host-only-app-secret';
|
||||||
|
let onCredentials;
|
||||||
|
let registrationState = 'idle';
|
||||||
|
let configured = false;
|
||||||
|
let connectionStatus = {
|
||||||
|
ready: false,
|
||||||
|
feishuLongConnectionState: 'idle',
|
||||||
|
harnessReachable: false,
|
||||||
|
};
|
||||||
|
const saved = [];
|
||||||
|
const connectedWith = [];
|
||||||
|
const controller = createProvisioningBackedController({
|
||||||
|
createProvisioningManager(callbacks) {
|
||||||
|
onCredentials = callbacks.onCredentials;
|
||||||
|
return {
|
||||||
|
start() { registrationState = 'qr_ready'; return this.status(); },
|
||||||
|
status() {
|
||||||
|
return {
|
||||||
|
state: registrationState,
|
||||||
|
attempt: 1,
|
||||||
|
updatedAt: 100,
|
||||||
|
...(registrationState === 'qr_ready'
|
||||||
|
? { qrCodeUrl: 'https://accounts.feishu.cn/qr', expiresAt: Date.now() + 60_000 }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
cancel() { registrationState = 'cancelled'; return this.status(); },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
credentialStore: {
|
||||||
|
async save(credentials) { saved.push(credentials); configured = true; },
|
||||||
|
async clear() { configured = false; },
|
||||||
|
async configured() { return configured; },
|
||||||
|
},
|
||||||
|
connectionManager: {
|
||||||
|
async connect(credentials) {
|
||||||
|
connectedWith.push(credentials);
|
||||||
|
connectionStatus = {
|
||||||
|
ready: true,
|
||||||
|
feishuLongConnectionState: 'connected',
|
||||||
|
harnessReachable: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async disconnect() {
|
||||||
|
connectionStatus = {
|
||||||
|
ready: false,
|
||||||
|
feishuLongConnectionState: 'idle',
|
||||||
|
harnessReachable: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
status: () => connectionStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
|
||||||
|
const begun = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.beginProvisioning,
|
||||||
|
{ locale: 'zh-CN' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(begun.ok, true);
|
||||||
|
|
||||||
|
await onCredentials({
|
||||||
|
client_id: 'cli_created',
|
||||||
|
client_secret: secret,
|
||||||
|
user_info: { open_id: 'ou_owner' },
|
||||||
|
});
|
||||||
|
registrationState = 'succeeded';
|
||||||
|
const ready = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||||
|
|
||||||
|
assert.equal(ready.ok, true);
|
||||||
|
assert.equal(ready.value.state, 'connected');
|
||||||
|
assert.equal(ready.value.connected, true);
|
||||||
|
assert.equal(saved[0].appSecret, secret);
|
||||||
|
assert.equal(connectedWith[0].appSecret, secret);
|
||||||
|
assert.doesNotMatch(JSON.stringify(ready), new RegExp(secret));
|
||||||
|
|
||||||
|
const disconnected = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.disconnect,
|
||||||
|
{ removeCredentials: true },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(disconnected.ok, true);
|
||||||
|
assert.equal(disconnected.value.state, 'disconnected');
|
||||||
|
assert.equal(configured, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DSH credential adapter stores refs off the browser plane and clears them', async () => {
|
||||||
|
const values = new Map();
|
||||||
|
const provider = {
|
||||||
|
async resolve(ref) {
|
||||||
|
return values.has(ref) ? { value: values.get(ref), source: 'file' } : undefined;
|
||||||
|
},
|
||||||
|
async describe(ref) {
|
||||||
|
return { configured: values.has(ref), source: values.has(ref) ? 'file' : undefined, writable: true };
|
||||||
|
},
|
||||||
|
async set(ref, value) { values.set(ref, value); },
|
||||||
|
async unset(ref) { values.delete(ref); },
|
||||||
|
};
|
||||||
|
const store = createDshCredentialStore(provider);
|
||||||
|
|
||||||
|
await store.save({ appId: 'cli_created', appSecret: 'stored-secret' });
|
||||||
|
assert.equal(values.get(FEISHU_APP_ID_REF), 'cli_created');
|
||||||
|
assert.equal(values.get(FEISHU_APP_SECRET_REF), 'stored-secret');
|
||||||
|
assert.equal(await store.configured(), true);
|
||||||
|
|
||||||
|
await store.clear();
|
||||||
|
assert.equal(values.size, 0);
|
||||||
|
assert.equal(await store.configured(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('production assembly needs only ctx credentials and the active DSH webServer', async () => {
|
||||||
|
const constructed = {};
|
||||||
|
class FakeConfigStore {
|
||||||
|
constructor(path) { constructed.configPath = path; }
|
||||||
|
async load() { return this; }
|
||||||
|
}
|
||||||
|
class FakeStateStore {
|
||||||
|
constructor(path) {
|
||||||
|
constructed.statePath = path;
|
||||||
|
(constructed.statePaths ??= []).push(path);
|
||||||
|
}
|
||||||
|
async load() { return this; }
|
||||||
|
}
|
||||||
|
class FakeHarness {
|
||||||
|
constructor(options) { constructed.harness = options; }
|
||||||
|
async ensureRunning() { constructed.harnessReadyChecks = (constructed.harnessReadyChecks ?? 0) + 1; }
|
||||||
|
stopManagedProcess() { constructed.harnessStopped = true; }
|
||||||
|
}
|
||||||
|
class FakeRuntime {
|
||||||
|
constructor(options) { constructed.runtime = options; }
|
||||||
|
}
|
||||||
|
class FakeController {
|
||||||
|
constructor(options) { constructed.controller = options; }
|
||||||
|
async initialize() { constructed.initialized = true; }
|
||||||
|
status() { return { totals: { configured: 0, connected: 0 } }; }
|
||||||
|
async close() { constructed.closed = true; }
|
||||||
|
}
|
||||||
|
const credentials = {};
|
||||||
|
const production = await createProductionController({
|
||||||
|
credentials,
|
||||||
|
webServer: { port: 43123, host: '127.0.0.1' },
|
||||||
|
logger: console,
|
||||||
|
}, {
|
||||||
|
dshHome: '/tmp/dsh-feishu-host-test',
|
||||||
|
workspace: '/tmp/dsh-feishu-workspace',
|
||||||
|
}, {
|
||||||
|
lark: { registerApp: async () => ({}) },
|
||||||
|
Controller: FakeController,
|
||||||
|
ConfigStore: FakeConfigStore,
|
||||||
|
StateStore: FakeStateStore,
|
||||||
|
HarnessClient: FakeHarness,
|
||||||
|
FeishuRuntime: FakeRuntime,
|
||||||
|
verifyFeishuApp: async () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(constructed.initialized, undefined);
|
||||||
|
await production.ready;
|
||||||
|
assert.equal(constructed.initialized, true);
|
||||||
|
assert.equal(constructed.harnessReadyChecks, 1);
|
||||||
|
assert.equal(constructed.controller.credentials, credentials);
|
||||||
|
assert.equal(String(constructed.harness.baseUrl), 'http://127.0.0.1:43123/');
|
||||||
|
assert.equal(constructed.harness.autostart, false);
|
||||||
|
assert.match(constructed.configPath, /integrations\/dsh-feishu\/config\.json$/);
|
||||||
|
|
||||||
|
await constructed.controller.createRuntime({
|
||||||
|
config: {
|
||||||
|
appId: 'cli_created',
|
||||||
|
domain: 'feishu',
|
||||||
|
ownerOpenId: 'ou_owner',
|
||||||
|
},
|
||||||
|
appSecret: 'host-only',
|
||||||
|
});
|
||||||
|
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
|
||||||
|
assert.equal(constructed.runtime.appSecret, 'host-only');
|
||||||
|
await constructed.controller.createRuntime({
|
||||||
|
botId: 'bot_alpha',
|
||||||
|
config: {
|
||||||
|
id: 'bot_alpha',
|
||||||
|
appId: 'cli_alpha',
|
||||||
|
secretRef: 'DSH_FEISHU_APP_SECRET_ALPHA',
|
||||||
|
domain: 'feishu',
|
||||||
|
ownerOpenIds: ['ou_alpha'],
|
||||||
|
},
|
||||||
|
appSecret: 'alpha-secret',
|
||||||
|
});
|
||||||
|
const alphaState = constructed.runtime.state;
|
||||||
|
await constructed.controller.createRuntime({
|
||||||
|
botId: 'bot_beta',
|
||||||
|
config: {
|
||||||
|
id: 'bot_beta',
|
||||||
|
appId: 'cli_beta',
|
||||||
|
secretRef: 'DSH_FEISHU_APP_SECRET_BETA',
|
||||||
|
domain: 'feishu',
|
||||||
|
ownerOpenIds: ['ou_beta'],
|
||||||
|
},
|
||||||
|
appSecret: 'beta-secret',
|
||||||
|
});
|
||||||
|
const betaState = constructed.runtime.state;
|
||||||
|
assert.notEqual(alphaState, betaState);
|
||||||
|
assert.ok(constructed.statePaths.some((path) => /bots\/bot_alpha\/state\.json$/.test(path)));
|
||||||
|
assert.ok(constructed.statePaths.some((path) => /bots\/bot_beta\/state\.json$/.test(path)));
|
||||||
|
await production.close();
|
||||||
|
assert.equal(constructed.closed, true);
|
||||||
|
assert.equal(constructed.harnessStopped, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting', async () => {
|
||||||
|
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-feishu-production-state-isolation-'));
|
||||||
|
await mkdir(dataDir, { recursive: true });
|
||||||
|
await writeFile(join(dataDir, 'state.json'), '{corrupt legacy state');
|
||||||
|
const legacy = {
|
||||||
|
id: 'bot_legacy',
|
||||||
|
appId: 'cli_legacy',
|
||||||
|
secretRef: 'DSH_FEISHU_APP_SECRET',
|
||||||
|
ownerOpenIds: ['ou_legacy'],
|
||||||
|
domain: 'feishu',
|
||||||
|
botName: '旧机器人',
|
||||||
|
};
|
||||||
|
const healthy = {
|
||||||
|
id: 'bot_healthy',
|
||||||
|
appId: 'cli_healthy',
|
||||||
|
secretRef: 'DSH_FEISHU_APP_SECRET_HEALTHY',
|
||||||
|
ownerOpenIds: ['ou_healthy'],
|
||||||
|
domain: 'feishu',
|
||||||
|
botName: '健康机器人',
|
||||||
|
};
|
||||||
|
await writeFile(join(dataDir, 'config.json'), JSON.stringify({ version: 2, bots: [legacy, healthy] }));
|
||||||
|
const secrets = new Map([
|
||||||
|
[legacy.secretRef, 'legacy-secret'],
|
||||||
|
[healthy.secretRef, 'healthy-secret'],
|
||||||
|
]);
|
||||||
|
class FakeRuntime {
|
||||||
|
#status = { ready: false, feishuLongConnectionState: 'idle', harnessReachable: false };
|
||||||
|
get status() { return structuredClone(this.#status); }
|
||||||
|
async start() {
|
||||||
|
this.#status = { ready: true, feishuLongConnectionState: 'connected', harnessReachable: true };
|
||||||
|
}
|
||||||
|
async stop() {
|
||||||
|
this.#status = { ready: false, feishuLongConnectionState: 'idle', harnessReachable: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class FakeHarness {
|
||||||
|
async ensureRunning() {}
|
||||||
|
stopManagedProcess() {}
|
||||||
|
}
|
||||||
|
const production = await createProductionController({
|
||||||
|
credentials: {
|
||||||
|
async resolve(ref) { return secrets.has(ref) ? { value: secrets.get(ref) } : undefined; },
|
||||||
|
async set(ref, value) { secrets.set(ref, value); },
|
||||||
|
async unset(ref) { secrets.delete(ref); },
|
||||||
|
},
|
||||||
|
webServer: { port: 43124 },
|
||||||
|
logger: console,
|
||||||
|
}, { dataDir, workspace: dataDir }, {
|
||||||
|
lark: { registerApp: async () => ({}) },
|
||||||
|
HarnessClient: FakeHarness,
|
||||||
|
FeishuRuntime: FakeRuntime,
|
||||||
|
verifyFeishuApp: async () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await production.ready;
|
||||||
|
const statusValue = production.controller.status();
|
||||||
|
assert.equal(statusValue.bots.find((entry) => entry.botId === 'bot_legacy').phase, 'error');
|
||||||
|
assert.equal(statusValue.bots.find((entry) => entry.botId === 'bot_healthy').connected, true);
|
||||||
|
assert.equal(statusValue.totals.connected, 1);
|
||||||
|
await production.close();
|
||||||
|
});
|
||||||
280
test/channels/feishu/registration-manager.test.mjs
Normal file
280
test/channels/feishu/registration-manager.test.mjs
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { RegistrationManager } from '../../../src/channels/feishu/registration-manager.mjs';
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
let reject;
|
||||||
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeClock() {
|
||||||
|
let time = 0;
|
||||||
|
let sequence = 0;
|
||||||
|
const timers = new Map();
|
||||||
|
|
||||||
|
return {
|
||||||
|
now: () => time,
|
||||||
|
setTimeout(fn, delay) {
|
||||||
|
const id = ++sequence;
|
||||||
|
timers.set(id, { at: time + delay, fn });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearTimeout(id) {
|
||||||
|
timers.delete(id);
|
||||||
|
},
|
||||||
|
advance(milliseconds) {
|
||||||
|
const target = time + milliseconds;
|
||||||
|
for (;;) {
|
||||||
|
const due = [...timers.entries()]
|
||||||
|
.filter(([, timer]) => timer.at <= target)
|
||||||
|
.sort((left, right) => left[1].at - right[1].at)[0];
|
||||||
|
if (!due) break;
|
||||||
|
const [id, timer] = due;
|
||||||
|
timers.delete(id);
|
||||||
|
time = timer.at;
|
||||||
|
timer.fn();
|
||||||
|
}
|
||||||
|
time = target;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPromises() {
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('RegistrationManager captures QR countdown and SDK polling states', async () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const registration = deferred();
|
||||||
|
let sdkOptions;
|
||||||
|
const manager = new RegistrationManager({
|
||||||
|
registerApp: (options) => {
|
||||||
|
sdkOptions = options;
|
||||||
|
return registration.promise;
|
||||||
|
},
|
||||||
|
onCredentials: async () => {},
|
||||||
|
now: clock.now,
|
||||||
|
setTimeout: clock.setTimeout,
|
||||||
|
clearTimeout: clock.clearTimeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(manager.start({ source: 'dsh-feishu' }), {
|
||||||
|
state: 'starting',
|
||||||
|
attempt: 1,
|
||||||
|
updatedAt: 0,
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
assert.equal(sdkOptions.source, 'dsh-feishu');
|
||||||
|
assert.ok(sdkOptions.signal instanceof AbortSignal);
|
||||||
|
|
||||||
|
sdkOptions.onQRCodeReady({ url: 'https://accounts.feishu.cn/device', expireIn: 5 });
|
||||||
|
assert.deepEqual(manager.status(), {
|
||||||
|
state: 'qr_ready',
|
||||||
|
attempt: 1,
|
||||||
|
updatedAt: 0,
|
||||||
|
qrCodeUrl: 'https://accounts.feishu.cn/device',
|
||||||
|
expiresAt: 5000,
|
||||||
|
remainingSeconds: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
clock.advance(1200);
|
||||||
|
assert.equal(manager.status().remainingSeconds, 4);
|
||||||
|
|
||||||
|
sdkOptions.onStatusChange({ status: 'polling' });
|
||||||
|
assert.equal(manager.status().state, 'polling');
|
||||||
|
|
||||||
|
sdkOptions.onStatusChange({ status: 'slow_down', interval: 10 });
|
||||||
|
assert.equal(manager.status().state, 'slow_down');
|
||||||
|
assert.equal(manager.status().pollIntervalSeconds, 10);
|
||||||
|
|
||||||
|
sdkOptions.onStatusChange({ status: 'domain_switched' });
|
||||||
|
assert.equal(manager.status().state, 'domain_switched');
|
||||||
|
assert.equal(manager.status().remainingSeconds, 4);
|
||||||
|
|
||||||
|
manager.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RegistrationManager only sends credentials to callback and never exposes the secret', async () => {
|
||||||
|
const registration = deferred();
|
||||||
|
const persistence = deferred();
|
||||||
|
const received = [];
|
||||||
|
let sdkOptions;
|
||||||
|
const manager = new RegistrationManager({
|
||||||
|
registerApp: (options) => {
|
||||||
|
sdkOptions = options;
|
||||||
|
return registration.promise;
|
||||||
|
},
|
||||||
|
onCredentials: (credentials) => {
|
||||||
|
received.push(credentials);
|
||||||
|
return persistence.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.start();
|
||||||
|
await flushPromises();
|
||||||
|
sdkOptions.onQRCodeReady({ url: 'https://example.test/qr', expireIn: 60 });
|
||||||
|
registration.resolve({
|
||||||
|
client_id: 'cli_test',
|
||||||
|
client_secret: 'super-secret-value',
|
||||||
|
user_info: { open_id: 'ou_test', tenant_brand: 'feishu' },
|
||||||
|
unexpected: 'must-not-be-forwarded',
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
assert.deepEqual(received, [{
|
||||||
|
client_id: 'cli_test',
|
||||||
|
client_secret: 'super-secret-value',
|
||||||
|
user_info: { open_id: 'ou_test', tenant_brand: 'feishu' },
|
||||||
|
}]);
|
||||||
|
assert.equal(manager.status().state, 'saving');
|
||||||
|
assert.equal('qrCodeUrl' in manager.status(), false);
|
||||||
|
assert.equal('remainingSeconds' in manager.status(), false);
|
||||||
|
assert.doesNotMatch(JSON.stringify(manager.status()), /super-secret-value|client_secret/);
|
||||||
|
|
||||||
|
persistence.resolve();
|
||||||
|
await flushPromises();
|
||||||
|
assert.equal(manager.status().state, 'succeeded');
|
||||||
|
assert.doesNotMatch(JSON.stringify(manager.status()), /super-secret-value|client_secret|cli_test|ou_test/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a concurrent start aborts and ignores the previous attempt', async () => {
|
||||||
|
const registrations = [deferred(), deferred()];
|
||||||
|
const sdkCalls = [];
|
||||||
|
const received = [];
|
||||||
|
const manager = new RegistrationManager({
|
||||||
|
registerApp: (options) => {
|
||||||
|
const index = sdkCalls.length;
|
||||||
|
sdkCalls.push(options);
|
||||||
|
return registrations[index].promise;
|
||||||
|
},
|
||||||
|
onCredentials: async (credentials) => received.push(credentials.client_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.start();
|
||||||
|
await flushPromises();
|
||||||
|
manager.start();
|
||||||
|
assert.equal(sdkCalls[0].signal.aborted, true);
|
||||||
|
assert.equal(manager.status().attempt, 2);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
sdkCalls[0].onQRCodeReady({ url: 'https://stale.test', expireIn: 30 });
|
||||||
|
registrations[0].resolve({ client_id: 'cli_stale', client_secret: 'stale-secret' });
|
||||||
|
registrations[1].resolve({ client_id: 'cli_current', client_secret: 'current-secret' });
|
||||||
|
await flushPromises();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
assert.deepEqual(received, ['cli_current']);
|
||||||
|
assert.equal(manager.status().state, 'succeeded');
|
||||||
|
assert.doesNotMatch(JSON.stringify(manager.status()), /stale-secret|current-secret/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel is terminal and ignores a late SDK result', async () => {
|
||||||
|
const registration = deferred();
|
||||||
|
const received = [];
|
||||||
|
let sdkOptions;
|
||||||
|
const manager = new RegistrationManager({
|
||||||
|
registerApp: (options) => {
|
||||||
|
sdkOptions = options;
|
||||||
|
return registration.promise;
|
||||||
|
},
|
||||||
|
onCredentials: async (credentials) => received.push(credentials),
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.start();
|
||||||
|
await flushPromises();
|
||||||
|
const status = manager.cancel();
|
||||||
|
|
||||||
|
assert.equal(status.state, 'cancelled');
|
||||||
|
assert.equal(status.error.code, 'abort');
|
||||||
|
assert.equal(sdkOptions.signal.aborted, true);
|
||||||
|
|
||||||
|
registration.resolve({ client_id: 'cli_late', client_secret: 'late-secret' });
|
||||||
|
await flushPromises();
|
||||||
|
assert.deepEqual(received, []);
|
||||||
|
assert.equal(manager.status().state, 'cancelled');
|
||||||
|
assert.doesNotMatch(JSON.stringify(manager.status()), /late-secret/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QR expiry aborts polling and reports an explicit expired state', async () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const registration = deferred();
|
||||||
|
let sdkOptions;
|
||||||
|
const manager = new RegistrationManager({
|
||||||
|
registerApp: (options) => {
|
||||||
|
sdkOptions = options;
|
||||||
|
return registration.promise;
|
||||||
|
},
|
||||||
|
onCredentials: async () => assert.fail('expired credentials must not be stored'),
|
||||||
|
now: clock.now,
|
||||||
|
setTimeout: clock.setTimeout,
|
||||||
|
clearTimeout: clock.clearTimeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.start();
|
||||||
|
await flushPromises();
|
||||||
|
sdkOptions.onQRCodeReady({ url: 'https://example.test/expiring', expireIn: 3 });
|
||||||
|
clock.advance(2999);
|
||||||
|
assert.equal(manager.status().remainingSeconds, 1);
|
||||||
|
clock.advance(1);
|
||||||
|
|
||||||
|
assert.equal(manager.status().state, 'expired');
|
||||||
|
assert.equal(manager.status().error.code, 'expired_token');
|
||||||
|
assert.equal('qrCodeUrl' in manager.status(), false);
|
||||||
|
assert.equal(sdkOptions.signal.aborted, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SDK expiration and errors become distinct safe terminal states', async (t) => {
|
||||||
|
await t.test('expired_token is expired', async () => {
|
||||||
|
const manager = new RegistrationManager({
|
||||||
|
registerApp: async () => {
|
||||||
|
throw { code: 'expired_token', description: 'Polling timed out' };
|
||||||
|
},
|
||||||
|
onCredentials: async () => {},
|
||||||
|
});
|
||||||
|
manager.start();
|
||||||
|
await flushPromises();
|
||||||
|
assert.equal(manager.status().state, 'expired');
|
||||||
|
assert.equal(manager.status().error.code, 'expired_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('unknown error does not reflect its message', async () => {
|
||||||
|
const manager = new RegistrationManager({
|
||||||
|
registerApp: async () => {
|
||||||
|
throw new Error('request failed with client_secret=do-not-leak');
|
||||||
|
},
|
||||||
|
onCredentials: async () => {},
|
||||||
|
});
|
||||||
|
manager.start();
|
||||||
|
await flushPromises();
|
||||||
|
const status = manager.status();
|
||||||
|
assert.equal(status.state, 'error');
|
||||||
|
assert.equal(status.error.code, 'registration_failed');
|
||||||
|
assert.doesNotMatch(JSON.stringify(status), /do-not-leak|client_secret/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credential persistence failure is safe and does not expose the secret', async () => {
|
||||||
|
const manager = new RegistrationManager({
|
||||||
|
registerApp: async () => ({
|
||||||
|
client_id: 'cli_test',
|
||||||
|
client_secret: 'secret-mentioned-by-callback',
|
||||||
|
user_info: { open_id: 'ou_test' },
|
||||||
|
}),
|
||||||
|
onCredentials: async ({ client_secret: secret }) => {
|
||||||
|
throw new Error(`failed to persist ${secret}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.start();
|
||||||
|
await flushPromises();
|
||||||
|
await flushPromises();
|
||||||
|
const status = manager.status();
|
||||||
|
assert.equal(status.state, 'error');
|
||||||
|
assert.equal(status.error.code, 'credentials_callback_failed');
|
||||||
|
assert.doesNotMatch(JSON.stringify(status), /secret-mentioned-by-callback|client_secret/);
|
||||||
|
});
|
||||||
34
test/channels/feishu/state-store.test.mjs
Normal file
34
test/channels/feishu/state-store.test.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { StateStore } from '../../../src/channels/feishu/state-store.mjs';
|
||||||
|
|
||||||
|
test('StateStore persists sessions and dedupe ids', async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-state-'));
|
||||||
|
const path = join(dir, 'state.json');
|
||||||
|
const first = await new StateStore(path).load();
|
||||||
|
await first.setSession('group:one', 'session-one');
|
||||||
|
await first.markSeen('message-one');
|
||||||
|
|
||||||
|
const second = await new StateStore(path).load();
|
||||||
|
assert.equal(second.sessionFor('group:one'), 'session-one');
|
||||||
|
assert.equal(second.hasSeen('message-one'), true);
|
||||||
|
assert.equal(JSON.parse(await readFile(path, 'utf8')).version, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separate bot StateStores isolate identical conversations and message ids', async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-state-bots-'));
|
||||||
|
const alpha = await new StateStore(join(dir, 'bot-alpha', 'state.json')).load();
|
||||||
|
const beta = await new StateStore(join(dir, 'bot-beta', 'state.json')).load();
|
||||||
|
|
||||||
|
await alpha.setSession('p2p:ou_same', 'session-alpha');
|
||||||
|
await beta.setSession('p2p:ou_same', 'session-beta');
|
||||||
|
await alpha.markSeen('om_same');
|
||||||
|
|
||||||
|
assert.equal(alpha.sessionFor('p2p:ou_same'), 'session-alpha');
|
||||||
|
assert.equal(beta.sessionFor('p2p:ou_same'), 'session-beta');
|
||||||
|
assert.equal(alpha.hasSeen('om_same'), true);
|
||||||
|
assert.equal(beta.hasSeen('om_same'), false);
|
||||||
|
});
|
||||||
68
test/channels/weixin/connection-supervisor.test.mjs
Normal file
68
test/channels/weixin/connection-supervisor.test.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { ConnectionSupervisor } from '../../../plugin-src/host/channels/weixin/connection-supervisor.mjs';
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
function scheduler() {
|
||||||
|
const pending = [];
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
setTimeoutImpl(callback, delay) {
|
||||||
|
const handle = { callback, delay, cancelled: false, unref() {} };
|
||||||
|
pending.push(handle);
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
clearTimeoutImpl(handle) { handle.cancelled = true; },
|
||||||
|
async runNext() {
|
||||||
|
const handle = pending.shift();
|
||||||
|
assert.ok(handle);
|
||||||
|
assert.equal(handle.cancelled, false);
|
||||||
|
handle.callback();
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
return handle.delay;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('supervisor waits for the in-process Harness API and retries offline Weixin accounts', async () => {
|
||||||
|
const timers = scheduler();
|
||||||
|
let healthChecks = 0;
|
||||||
|
let initializations = 0;
|
||||||
|
const supervisor = new ConnectionSupervisor({
|
||||||
|
harness: {
|
||||||
|
async ensureRunning() {
|
||||||
|
healthChecks += 1;
|
||||||
|
if (healthChecks === 1) throw new Error('Host is still starting');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
controller: {
|
||||||
|
async initialize() {
|
||||||
|
initializations += 1;
|
||||||
|
return {
|
||||||
|
totals: { configured: 1, connected: initializations > 1 ? 1 : 0 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
status() {},
|
||||||
|
},
|
||||||
|
logger: { warn() {} },
|
||||||
|
retryDelaysMs: [5, 10],
|
||||||
|
healthyIntervalMs: 100,
|
||||||
|
setTimeoutImpl: timers.setTimeoutImpl,
|
||||||
|
clearTimeoutImpl: timers.clearTimeoutImpl,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
assert.equal(await timers.runNext(), 0);
|
||||||
|
assert.equal(timers.pending[0].delay, 5);
|
||||||
|
await timers.runNext();
|
||||||
|
assert.equal(initializations, 1);
|
||||||
|
assert.equal(timers.pending[0].delay, 10);
|
||||||
|
await timers.runNext();
|
||||||
|
assert.equal(initializations, 2);
|
||||||
|
assert.equal((await supervisor.ready).totals.connected, 0);
|
||||||
|
assert.equal(timers.pending[0].delay, 100);
|
||||||
|
await supervisor.close();
|
||||||
|
assert.equal(timers.pending[0].cancelled, true);
|
||||||
|
});
|
||||||
48
test/channels/weixin/harness-client.test.mjs
Normal file
48
test/channels/weixin/harness-client.test.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { HarnessReplyTracker } from '../../../src/channels/weixin/harness-client.mjs';
|
||||||
|
|
||||||
|
test('reply tracker associates only the Harness turn created by the Weixin prompt RPC', () => {
|
||||||
|
const tracker = new HarnessReplyTracker({ promptRpcId: 'weixin-prompt', afterSeq: 2 });
|
||||||
|
const first = tracker.consume([
|
||||||
|
{ event: { seq: 3, type: 'turn/start', data: { turn: 9 } } },
|
||||||
|
{ event: {
|
||||||
|
seq: 4,
|
||||||
|
type: 'user/message',
|
||||||
|
data: { turn: 9, source: { rpcId: 'weixin-prompt' } },
|
||||||
|
} },
|
||||||
|
{ event: {
|
||||||
|
seq: 5,
|
||||||
|
type: 'assistant/chunk',
|
||||||
|
data: { turn: 9, step: 0, chunk: { type: 'text-delta', index: 0, text: '微信' } },
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(first, { type: 'text', text: '微信' });
|
||||||
|
tracker.consume([
|
||||||
|
{ event: {
|
||||||
|
seq: 6,
|
||||||
|
type: 'assistant/message',
|
||||||
|
data: { turn: 9, message: { content: [{ type: 'text', text: '微信回复完成' }] } },
|
||||||
|
} },
|
||||||
|
{ event: { seq: 7, type: 'turn/end', data: { turn: 9, reason: 'completed' } } },
|
||||||
|
]);
|
||||||
|
assert.equal(tracker.finished, true);
|
||||||
|
assert.equal(tracker.answer, '微信回复完成');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reply tracker ignores interleaved turns and older events', () => {
|
||||||
|
const tracker = new HarnessReplyTracker({ promptRpcId: 'target', afterSeq: 10 });
|
||||||
|
tracker.consume([
|
||||||
|
{ event: { seq: 9, type: 'turn/start', data: { turn: 1 } } },
|
||||||
|
{ event: { seq: 11, type: 'turn/start', data: { turn: 2 } } },
|
||||||
|
{ event: { seq: 12, type: 'user/message', data: { turn: 2, source: { rpcId: 'other' } } } },
|
||||||
|
{ event: {
|
||||||
|
seq: 13,
|
||||||
|
type: 'assistant/message',
|
||||||
|
data: { turn: 2, message: { content: [{ type: 'text', text: 'wrong' }] } },
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
assert.equal(tracker.answer, '');
|
||||||
|
assert.equal(tracker.finished, false);
|
||||||
|
});
|
||||||
92
test/channels/weixin/plugin-host.test.mjs
Normal file
92
test/channels/weixin/plugin-host.test.mjs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WEIXIN_ENDPOINTS,
|
||||||
|
apply,
|
||||||
|
createWeixinRpcHandler,
|
||||||
|
} from '../../../plugin-src/host/channels/weixin/index.mjs';
|
||||||
|
|
||||||
|
function snapshot(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
revision: 1,
|
||||||
|
state: 'disconnected',
|
||||||
|
bots: [],
|
||||||
|
totals: { configured: 0, connected: 0 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function controllerFixture() {
|
||||||
|
const attempts = new Map();
|
||||||
|
const controller = {
|
||||||
|
status: () => snapshot(),
|
||||||
|
startProvisioning: async () => {
|
||||||
|
const value = {
|
||||||
|
attemptId: 'attempt-1',
|
||||||
|
status: 'pending',
|
||||||
|
verificationUrl: 'https://liteapp.weixin.qq.com/q/test',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
pollIntervalMs: 1_000,
|
||||||
|
};
|
||||||
|
attempts.set(value.attemptId, value);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
registrationStatus: (id) => attempts.get(id) ?? null,
|
||||||
|
submitVerification: async (id) => ({ ...attempts.get(id), status: 'scanned' }),
|
||||||
|
cancelProvisioning: async (id) => ({ ...attempts.get(id), status: 'cancelled' }),
|
||||||
|
reconnectBot: async () => snapshot(),
|
||||||
|
deleteBot: async () => snapshot(),
|
||||||
|
};
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Host plugin registers the Weixin RPC channel as loopback-only', async () => {
|
||||||
|
let registration;
|
||||||
|
const dispose = async () => {};
|
||||||
|
const ctx = {
|
||||||
|
connection: { rpc: { handle: (channel, handler, options) => {
|
||||||
|
registration = { channel, handler, options };
|
||||||
|
return dispose;
|
||||||
|
} } },
|
||||||
|
};
|
||||||
|
const returned = await apply(ctx, { controller: controllerFixture() });
|
||||||
|
assert.equal(returned, dispose);
|
||||||
|
assert.equal(registration.channel, '/weixin');
|
||||||
|
assert.deepEqual(registration.options, { authority: 'loopback' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RPC returns QR data and verification states without exposing secret-shaped fields', async () => {
|
||||||
|
const controller = controllerFixture();
|
||||||
|
const handler = createWeixinRpcHandler(controller, {
|
||||||
|
encodeQr: async (url) => `data:image/png;base64,${Buffer.from(url).toString('base64')}`,
|
||||||
|
});
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
|
const begun = await handler(WEIXIN_ENDPOINTS.beginProvisioning, {}, signal);
|
||||||
|
assert.equal(begun.ok, true);
|
||||||
|
assert.match(begun.value.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||||
|
const verified = await handler(WEIXIN_ENDPOINTS.submitVerification, {
|
||||||
|
attemptId: 'attempt-1', verifyCode: '123456',
|
||||||
|
}, signal);
|
||||||
|
assert.equal(verified.ok, true);
|
||||||
|
assert.equal(verified.value.status, 'scanned');
|
||||||
|
|
||||||
|
const secretAttempt = await handler(WEIXIN_ENDPOINTS.beginProvisioning, {
|
||||||
|
bot_token: 'must-never-cross-the-browser-boundary',
|
||||||
|
}, signal);
|
||||||
|
assert.equal(secretAttempt.ok, false);
|
||||||
|
assert.equal(secretAttempt.error.code, 'bad-request');
|
||||||
|
assert.doesNotMatch(JSON.stringify(secretAttempt), /must-never-cross/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RPC requires explicit confirmation before removing a Weixin account', async () => {
|
||||||
|
const handler = createWeixinRpcHandler(controllerFixture());
|
||||||
|
const result = await handler(WEIXIN_ENDPOINTS.deleteBot, {
|
||||||
|
botId: 'wx_0123456789abcdef01234567',
|
||||||
|
confirm: false,
|
||||||
|
}, new AbortController().signal);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.error.code, 'bad-request');
|
||||||
|
});
|
||||||
62
test/channels/weixin/stores.test.mjs
Normal file
62
test/channels/weixin/stores.test.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deriveWeixinBotIdentity,
|
||||||
|
WeixinConfigStore,
|
||||||
|
} from '../../../src/channels/weixin/config-store.mjs';
|
||||||
|
import { WeixinStateStore } from '../../../src/channels/weixin/state-store.mjs';
|
||||||
|
|
||||||
|
test('config store persists non-secret account facts atomically with restrictive permissions', async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'dsh-weixin-config-'));
|
||||||
|
const path = join(root, 'nested', 'config.json');
|
||||||
|
const store = await new WeixinConfigStore(path).load();
|
||||||
|
const identity = deriveWeixinBotIdentity('account@im.bot');
|
||||||
|
await store.save({
|
||||||
|
...identity,
|
||||||
|
accountId: 'account@im.bot',
|
||||||
|
ownerUserId: 'owner-user',
|
||||||
|
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||||
|
createdAt: '2026-08-15T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = await readFile(path, 'utf8');
|
||||||
|
assert.match(raw, /"accountId": "account@im\.bot"/);
|
||||||
|
assert.doesNotMatch(raw, /bot_token|secret-token/);
|
||||||
|
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||||
|
assert.deepEqual((await new WeixinConfigStore(path).load()).list(), store.list());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config store rejects duplicate or tampered identities', async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'dsh-weixin-invalid-'));
|
||||||
|
const path = join(root, 'config.json');
|
||||||
|
await writeFile(path, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
accounts: [{
|
||||||
|
botId: 'wx_000000000000000000000000',
|
||||||
|
accountId: 'real@im.bot',
|
||||||
|
tokenRef: 'DSH_WEIXIN_BOT_TOKEN_000000000000000000000000',
|
||||||
|
ownerUserId: 'owner',
|
||||||
|
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
await assert.rejects(new WeixinConfigStore(path).load(), /invalid account data/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state store retains sessions, deduplication, and the getUpdates cursor', async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'dsh-weixin-state-'));
|
||||||
|
const path = join(root, 'account', 'state.json');
|
||||||
|
const state = await new WeixinStateStore(path).load();
|
||||||
|
await state.setSession('p2p:user', 'session-1');
|
||||||
|
await state.markSeen('message-1');
|
||||||
|
await state.setGetUpdatesBuf('cursor-2');
|
||||||
|
|
||||||
|
const restored = await new WeixinStateStore(path).load();
|
||||||
|
assert.equal(restored.sessionFor('p2p:user'), 'session-1');
|
||||||
|
assert.equal(restored.hasSeen('message-1'), true);
|
||||||
|
assert.equal(restored.getUpdatesBuf(), 'cursor-2');
|
||||||
|
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||||
|
});
|
||||||
124
test/channels/weixin/weixin-api.test.mjs
Normal file
124
test/channels/weixin/weixin-api.test.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createWeixinApi,
|
||||||
|
extractWeixinText,
|
||||||
|
normalizeWeixinApiBaseUrl,
|
||||||
|
splitWeixinText,
|
||||||
|
WeixinApiError,
|
||||||
|
} from '../../../src/channels/weixin/weixin-api.mjs';
|
||||||
|
|
||||||
|
function jsonResponse(value, init) {
|
||||||
|
return new Response(JSON.stringify(value), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('QR login uses the Tencent iLink headers and keeps local tokens out of the URL', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const api = createWeixinApi({
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
calls.push({ url: url.toString(), init });
|
||||||
|
return jsonResponse({
|
||||||
|
qrcode: 'private-qr-token',
|
||||||
|
qrcode_img_content: 'https://liteapp.weixin.qq.com/q/example',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = await api.beginLogin({ localTokens: [' token-a ', 'token-a', 'token-b'] });
|
||||||
|
|
||||||
|
assert.deepEqual(login, {
|
||||||
|
qrcode: 'private-qr-token',
|
||||||
|
qrcodeUrl: 'https://liteapp.weixin.qq.com/q/example',
|
||||||
|
});
|
||||||
|
assert.match(calls[0].url, /ilink\/bot\/get_bot_qrcode\?bot_type=3$/);
|
||||||
|
assert.doesNotMatch(calls[0].url, /token-a|token-b/);
|
||||||
|
assert.equal(calls[0].init.headers['iLink-App-Id'], 'bot');
|
||||||
|
assert.equal(calls[0].init.headers['iLink-App-ClientVersion'], String((2 << 16) | (4 << 8) | 6));
|
||||||
|
assert.equal(calls[0].init.headers.Authorization, undefined);
|
||||||
|
assert.deepEqual(JSON.parse(calls[0].init.body), { local_token_list: ['token-a', 'token-b'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login polling submits a verification code only to an approved Weixin host', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const api = createWeixinApi({
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
calls.push({ url: url.toString(), init });
|
||||||
|
return jsonResponse({ status: 'scaned' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await api.pollLogin({
|
||||||
|
qrcode: 'secret-qr',
|
||||||
|
baseUrl: 'https://shard.ilinkai.weixin.qq.com',
|
||||||
|
verifyCode: '123456',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(status.status, 'scaned');
|
||||||
|
assert.match(calls[0].url, /qrcode=secret-qr&verify_code=123456$/);
|
||||||
|
assert.equal(calls[0].init.method, 'GET');
|
||||||
|
assert.equal(calls[0].init.headers.AuthorizationType, undefined);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
api.pollLogin({ qrcode: 'secret', baseUrl: 'https://attacker.test' }),
|
||||||
|
(error) => error instanceof WeixinApiError && error.code === 'untrusted-base-url',
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendText emits the iLink message envelope without reflecting the token in its body', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const api = createWeixinApi({
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
calls.push({ url: url.toString(), init });
|
||||||
|
return jsonResponse({ ret: 0 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await api.sendText({
|
||||||
|
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||||
|
token: 'host-only-token',
|
||||||
|
toUserId: 'wx-user',
|
||||||
|
text: 'Harness reply',
|
||||||
|
contextToken: 'message-context',
|
||||||
|
runId: 'run-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(calls[0].init.headers.Authorization, 'Bearer host-only-token');
|
||||||
|
const body = JSON.parse(calls[0].init.body);
|
||||||
|
assert.equal(body.msg.to_user_id, 'wx-user');
|
||||||
|
assert.equal(body.msg.context_token, 'message-context');
|
||||||
|
assert.equal(body.msg.item_list[0].text_item.text, 'Harness reply');
|
||||||
|
assert.equal(body.base_info.channel_version, '2.4.6');
|
||||||
|
assert.equal(body.base_info.bot_agent, 'DeepSeekHarness/0.1.0');
|
||||||
|
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUpdates converts its own long-poll timeout into an empty successful poll', async () => {
|
||||||
|
const api = createWeixinApi({
|
||||||
|
fetchImpl: async (_url, init) => new Promise((_resolve, reject) => {
|
||||||
|
init.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await api.getUpdates({
|
||||||
|
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||||
|
token: 'token',
|
||||||
|
getUpdatesBuf: 'cursor',
|
||||||
|
timeoutMs: 2,
|
||||||
|
});
|
||||||
|
assert.deepEqual(result, { ret: 0, msgs: [], get_updates_buf: 'cursor' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Weixin URL, inbound text, and reply chunk helpers enforce their narrow formats', () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeWeixinApiBaseUrl('https://ilinkai.weixin.qq.com/path'),
|
||||||
|
'https://ilinkai.weixin.qq.com/path/',
|
||||||
|
);
|
||||||
|
assert.throws(() => normalizeWeixinApiBaseUrl('https://ilinkai.weixin.qq.com:444/'));
|
||||||
|
assert.equal(extractWeixinText({ item_list: [{ type: 1, text_item: { text: ' 你好 ' } }] }), '你好');
|
||||||
|
assert.equal(extractWeixinText({ item_list: [{ type: 3, voice_item: { text: '语音转写' } }] }), '语音转写');
|
||||||
|
assert.deepEqual(splitWeixinText('abcdefgh', 5), ['abcde', 'fgh']);
|
||||||
|
});
|
||||||
113
test/channels/weixin/weixin-bridge.test.mjs
Normal file
113
test/channels/weixin/weixin-bridge.test.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||||
|
|
||||||
|
function message(id, text, overrides = {}) {
|
||||||
|
return {
|
||||||
|
message_id: id,
|
||||||
|
message_type: 1,
|
||||||
|
from_user_id: 'owner-user',
|
||||||
|
context_token: `context-${id}`,
|
||||||
|
item_list: [{ type: 1, text_item: { text } }],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateFixture() {
|
||||||
|
const sessions = new Map();
|
||||||
|
const seen = new Set();
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
seen,
|
||||||
|
state: {
|
||||||
|
hasSeen: (id) => seen.has(id),
|
||||||
|
markSeen: async (id) => seen.add(id),
|
||||||
|
sessionFor: (key) => sessions.get(key) ?? null,
|
||||||
|
setSession: async (key, sessionId) => sessions.set(key, sessionId),
|
||||||
|
clearSession: async (key) => sessions.delete(key),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bridge maps the scanning Weixin user to one persistent Harness session and echoes context_token', async () => {
|
||||||
|
const sent = [];
|
||||||
|
const asked = [];
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const status = createWeixinBridgeStatus();
|
||||||
|
const bridge = new WeixinHarnessBridge({
|
||||||
|
api: { sendText: async (request) => sent.push(request) },
|
||||||
|
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||||
|
token: 'host-token',
|
||||||
|
ownerUserId: 'owner-user',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async (sessionId) => sessionId === 'session-1',
|
||||||
|
createSession: async () => 'session-1',
|
||||||
|
ask: async (sessionId, text) => {
|
||||||
|
asked.push({ sessionId, text });
|
||||||
|
return 'Harness 的回答';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('1', '你好'));
|
||||||
|
await bridge.accept(message('2', '继续'));
|
||||||
|
|
||||||
|
assert.deepEqual(asked, [
|
||||||
|
{ sessionId: 'session-1', text: '你好' },
|
||||||
|
{ sessionId: 'session-1', text: '继续' },
|
||||||
|
]);
|
||||||
|
assert.equal(fixture.sessions.get('p2p:owner-user'), 'session-1');
|
||||||
|
assert.deepEqual(sent.map(({ toUserId, text, contextToken }) => ({ toUserId, text, contextToken })), [
|
||||||
|
{ toUserId: 'owner-user', text: 'Harness 的回答', contextToken: 'context-1' },
|
||||||
|
{ toUserId: 'owner-user', text: 'Harness 的回答', contextToken: 'context-2' },
|
||||||
|
]);
|
||||||
|
assert.equal(status.messagesReceived, 2);
|
||||||
|
assert.equal(status.messagesReplied, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bridge rejects every user except the account owner returned by QR login', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
let asked = 0;
|
||||||
|
const status = createWeixinBridgeStatus();
|
||||||
|
const bridge = new WeixinHarnessBridge({
|
||||||
|
api: { sendText: async () => assert.fail('unauthorized users must not receive a reply') },
|
||||||
|
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||||
|
token: 'host-token',
|
||||||
|
ownerUserId: 'owner-user',
|
||||||
|
harness: { ask: async () => { asked += 1; } },
|
||||||
|
state: fixture.state,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('unauthorized', '越权', { from_user_id: 'other-user' }));
|
||||||
|
assert.equal(asked, 0);
|
||||||
|
assert.equal(status.messagesRejected, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bridge commands are local and internal failures return a generic message', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
fixture.sessions.set('p2p:owner-user', 'old-session');
|
||||||
|
const sent = [];
|
||||||
|
const bridge = new WeixinHarnessBridge({
|
||||||
|
api: { sendText: async (request) => sent.push(request.text) },
|
||||||
|
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||||
|
token: 'host-token',
|
||||||
|
ownerUserId: 'owner-user',
|
||||||
|
harness: {
|
||||||
|
ensureRunning: async () => true,
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async () => { throw new Error('private path /secret and token-shaped detail'); },
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
logger: { error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message('new', '/new'));
|
||||||
|
assert.equal(fixture.sessions.has('p2p:owner-user'), false);
|
||||||
|
await bridge.accept(message('failure', '触发失败'));
|
||||||
|
assert.match(sent.at(-1), /消息处理失败/);
|
||||||
|
assert.doesNotMatch(sent.at(-1), /private path|secret|token-shaped/);
|
||||||
|
});
|
||||||
218
test/channels/weixin/weixin-controller.test.mjs
Normal file
218
test/channels/weixin/weixin-controller.test.mjs
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { WeixinController } from '../../../src/channels/weixin/weixin-controller.mjs';
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
async function waitFor(read, predicate, attempts = 100) {
|
||||||
|
for (let index = 0; index < attempts; index += 1) {
|
||||||
|
const value = read();
|
||||||
|
if (predicate(value)) return value;
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
throw new Error('condition was not reached');
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialsFixture() {
|
||||||
|
const values = new Map();
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
calls,
|
||||||
|
provider: {
|
||||||
|
resolve: async (ref) => values.has(ref)
|
||||||
|
? { configured: true, source: 'settings', value: values.get(ref) }
|
||||||
|
: { configured: false },
|
||||||
|
set: async (ref, value) => { calls.push(['set', ref]); values.set(ref, value); },
|
||||||
|
unset: async (ref) => { calls.push(['unset', ref]); values.delete(ref); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configFixture() {
|
||||||
|
const accounts = new Map();
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
store: {
|
||||||
|
list: () => [...accounts.values()].map((account) => structuredClone(account)),
|
||||||
|
get: (botId) => accounts.has(botId) ? structuredClone(accounts.get(botId)) : null,
|
||||||
|
getByAccountId: (accountId) => {
|
||||||
|
const found = [...accounts.values()].find((account) => account.accountId === accountId);
|
||||||
|
return found ? structuredClone(found) : null;
|
||||||
|
},
|
||||||
|
save: async (account) => { accounts.set(account.botId, structuredClone(account)); return account; },
|
||||||
|
remove: async (botId) => accounts.delete(botId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeFactory({ failStart = false } = {}) {
|
||||||
|
const runtimes = [];
|
||||||
|
const createRuntime = async ({ config, token }) => {
|
||||||
|
let ready = false;
|
||||||
|
const runtime = {
|
||||||
|
config,
|
||||||
|
token,
|
||||||
|
get status() {
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
weixinConnectionState: ready ? 'connected' : 'idle',
|
||||||
|
harnessReachable: ready,
|
||||||
|
lastCheckedAt: ready ? 100 : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async start() {
|
||||||
|
if (failStart) throw new Error('runtime start failed with host-only detail');
|
||||||
|
ready = true;
|
||||||
|
},
|
||||||
|
async stop() { ready = false; },
|
||||||
|
};
|
||||||
|
runtimes.push(runtime);
|
||||||
|
return runtime;
|
||||||
|
};
|
||||||
|
return { runtimes, createRuntime };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
|
||||||
|
const credentials = credentialsFixture();
|
||||||
|
const configs = configFixture();
|
||||||
|
const runtimes = runtimeFactory();
|
||||||
|
const controller = new WeixinController({
|
||||||
|
api: {
|
||||||
|
beginLogin: async ({ localTokens }) => {
|
||||||
|
assert.deepEqual(localTokens, []);
|
||||||
|
return { qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' };
|
||||||
|
},
|
||||||
|
pollLogin: async () => ({
|
||||||
|
status: 'confirmed',
|
||||||
|
bot_token: 'private-bot-token',
|
||||||
|
ilink_bot_id: 'account@im.bot',
|
||||||
|
ilink_user_id: 'owner-user',
|
||||||
|
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimes.createRuntime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const begun = await controller.startProvisioning();
|
||||||
|
const completed = await waitFor(
|
||||||
|
() => controller.registrationStatus(begun.attemptId),
|
||||||
|
(value) => value.status === 'connected',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(completed.botId, /^wx_[a-f0-9]{24}$/);
|
||||||
|
assert.equal(credentials.values.size, 1);
|
||||||
|
assert.equal([...credentials.values.values()][0], 'private-bot-token');
|
||||||
|
const stored = [...configs.accounts.values()][0];
|
||||||
|
assert.equal(stored.ownerUserId, 'owner-user');
|
||||||
|
assert.equal('token' in stored, false);
|
||||||
|
assert.equal(runtimes.runtimes[0].token, 'private-bot-token');
|
||||||
|
const publicJson = JSON.stringify(controller.status());
|
||||||
|
assert.doesNotMatch(publicJson, /private-bot-token|owner-user|account@im\.bot|tokenRef/);
|
||||||
|
assert.equal(controller.status().totals.connected, 1);
|
||||||
|
|
||||||
|
await controller.deleteBot(completed.botId);
|
||||||
|
assert.equal(credentials.values.size, 0);
|
||||||
|
assert.equal(configs.accounts.size, 0);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verification-code state pauses polling and resumes with the submitted digits', async () => {
|
||||||
|
const credentials = credentialsFixture();
|
||||||
|
const configs = configFixture();
|
||||||
|
const runtimes = runtimeFactory();
|
||||||
|
const verifyCodes = [];
|
||||||
|
let polls = 0;
|
||||||
|
const controller = new WeixinController({
|
||||||
|
api: {
|
||||||
|
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||||
|
pollLogin: async ({ verifyCode }) => {
|
||||||
|
polls += 1;
|
||||||
|
verifyCodes.push(verifyCode);
|
||||||
|
if (polls === 1) return { status: 'need_verifycode' };
|
||||||
|
return {
|
||||||
|
status: 'confirmed',
|
||||||
|
bot_token: 'token-after-code',
|
||||||
|
ilink_bot_id: 'verify@im.bot',
|
||||||
|
ilink_user_id: 'verify-owner',
|
||||||
|
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimes.createRuntime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const begun = await controller.startProvisioning();
|
||||||
|
await waitFor(
|
||||||
|
() => controller.registrationStatus(begun.attemptId),
|
||||||
|
(value) => value.status === 'needs_verification',
|
||||||
|
);
|
||||||
|
assert.equal(polls, 1);
|
||||||
|
await controller.submitVerification(begun.attemptId, '123456');
|
||||||
|
await waitFor(
|
||||||
|
() => controller.registrationStatus(begun.attemptId),
|
||||||
|
(value) => value.status === 'connected',
|
||||||
|
);
|
||||||
|
assert.deepEqual(verifyCodes, [null, '123456']);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activation failure rolls credentials and non-secret config back', async () => {
|
||||||
|
const credentials = credentialsFixture();
|
||||||
|
const configs = configFixture();
|
||||||
|
const runtimes = runtimeFactory({ failStart: true });
|
||||||
|
const controller = new WeixinController({
|
||||||
|
api: {
|
||||||
|
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||||
|
pollLogin: async () => ({
|
||||||
|
status: 'confirmed',
|
||||||
|
bot_token: 'must-be-rolled-back',
|
||||||
|
ilink_bot_id: 'rollback@im.bot',
|
||||||
|
ilink_user_id: 'owner',
|
||||||
|
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimes.createRuntime,
|
||||||
|
logger: { error() {}, warn() {} },
|
||||||
|
});
|
||||||
|
const begun = await controller.startProvisioning();
|
||||||
|
const failed = await waitFor(
|
||||||
|
() => controller.registrationStatus(begun.attemptId),
|
||||||
|
(value) => value.status === 'failed',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(failed.error.code, 'activation-failed');
|
||||||
|
assert.equal(credentials.values.size, 0);
|
||||||
|
assert.equal(configs.accounts.size, 0);
|
||||||
|
assert.doesNotMatch(JSON.stringify(failed), /must-be-rolled-back|host-only detail/);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling an in-flight QR long poll is terminal and writes no credentials', async () => {
|
||||||
|
const credentials = credentialsFixture();
|
||||||
|
const configs = configFixture();
|
||||||
|
const controller = new WeixinController({
|
||||||
|
api: {
|
||||||
|
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||||
|
pollLogin: async ({ signal }) => new Promise((_resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimeFactory().createRuntime,
|
||||||
|
});
|
||||||
|
const begun = await controller.startProvisioning();
|
||||||
|
const cancelled = await controller.cancelProvisioning(begun.attemptId);
|
||||||
|
assert.equal(cancelled.status, 'cancelled');
|
||||||
|
assert.equal(credentials.values.size, 0);
|
||||||
|
assert.equal(configs.accounts.size, 0);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
91
test/channels/weixin/weixin-runtime.test.mjs
Normal file
91
test/channels/weixin/weixin-runtime.test.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { WeixinRuntime } from '../../../src/channels/weixin/weixin-runtime.mjs';
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
test('runtime verifies the token, consumes getUpdates, replies, persists cursor, and aborts on stop', async () => {
|
||||||
|
const calls = [];
|
||||||
|
let pollCount = 0;
|
||||||
|
const stateData = { cursor: '', seen: new Set(), session: null };
|
||||||
|
const api = {
|
||||||
|
notifyStart: async (request) => calls.push(['start', request.token]),
|
||||||
|
notifyStop: async (request) => calls.push(['stop', request.token]),
|
||||||
|
sendText: async (request) => calls.push(['send', request.text, request.contextToken]),
|
||||||
|
getUpdates: async ({ signal }) => {
|
||||||
|
pollCount += 1;
|
||||||
|
if (pollCount === 1) {
|
||||||
|
return {
|
||||||
|
ret: 0,
|
||||||
|
get_updates_buf: 'cursor-next',
|
||||||
|
msgs: [{
|
||||||
|
message_id: 7,
|
||||||
|
message_type: 1,
|
||||||
|
from_user_id: 'owner',
|
||||||
|
context_token: 'context-7',
|
||||||
|
item_list: [{ type: 1, text_item: { text: '问题' } }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const state = {
|
||||||
|
getUpdatesBuf: () => stateData.cursor,
|
||||||
|
setGetUpdatesBuf: async (value) => { stateData.cursor = value; },
|
||||||
|
hasSeen: (id) => stateData.seen.has(id),
|
||||||
|
markSeen: async (id) => stateData.seen.add(id),
|
||||||
|
sessionFor: () => stateData.session,
|
||||||
|
setSession: async (_key, value) => { stateData.session = value; },
|
||||||
|
clearSession: async () => { stateData.session = null; },
|
||||||
|
};
|
||||||
|
const runtime = new WeixinRuntime({
|
||||||
|
api,
|
||||||
|
config: {
|
||||||
|
botId: 'wx_bot',
|
||||||
|
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||||
|
ownerUserId: 'owner',
|
||||||
|
},
|
||||||
|
token: 'bot-token',
|
||||||
|
harness: {
|
||||||
|
ensureRunning: async () => true,
|
||||||
|
sessionExists: async () => true,
|
||||||
|
createSession: async () => 'session-1',
|
||||||
|
ask: async () => '回答',
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
logger: { warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = await runtime.start();
|
||||||
|
assert.equal(started.ready, true);
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
assert.equal(stateData.cursor, 'cursor-next');
|
||||||
|
assert.deepEqual(calls.slice(0, 2), [
|
||||||
|
['start', 'bot-token'],
|
||||||
|
['send', '回答', 'context-7'],
|
||||||
|
]);
|
||||||
|
await runtime.stop();
|
||||||
|
assert.deepEqual(calls.at(-1), ['stop', 'bot-token']);
|
||||||
|
assert.equal(runtime.status.ready, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime refuses to report ready when notifyStart rejects the stored token', async () => {
|
||||||
|
const runtime = new WeixinRuntime({
|
||||||
|
api: {
|
||||||
|
notifyStart: async () => { throw new Error('rejected'); },
|
||||||
|
notifyStop: async () => {},
|
||||||
|
},
|
||||||
|
config: { botId: 'wx_bad', baseUrl: 'https://ilinkai.weixin.qq.com/', ownerUserId: 'owner' },
|
||||||
|
token: 'bad-token',
|
||||||
|
harness: { ensureRunning: async () => true },
|
||||||
|
state: {},
|
||||||
|
});
|
||||||
|
await assert.rejects(runtime.start(), /rejected/);
|
||||||
|
assert.equal(runtime.status.ready, false);
|
||||||
|
assert.equal(runtime.status.weixinConnectionState, 'failed');
|
||||||
|
});
|
||||||
|
|
@ -6,11 +6,13 @@ import React from 'react';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
|
||||||
import { IMSettingsTab } from '../plugin-src/client/index.js';
|
import { IMSettingsTab } from '../plugin-src/client/index.js';
|
||||||
|
import { DINGTALK_ENDPOINTS } from '../plugin-src/client/channels/dingtalk/api.js';
|
||||||
|
|
||||||
const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url);
|
const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url);
|
||||||
const CLIENT_BUNDLE_URL = new URL('../lib/client.js', import.meta.url);
|
const CLIENT_BUNDLE_URL = new URL('../lib/client.js', import.meta.url);
|
||||||
const DINGTALK_CLIENT_SOURCE_URL = new URL(
|
const DINGTALK_CLIENT_SOURCE_URL = new URL(
|
||||||
import.meta.resolve('@xmanrui/dsh-dingtalk/client-source'),
|
'../plugin-src/client/channels/dingtalk/index.js',
|
||||||
|
import.meta.url,
|
||||||
);
|
);
|
||||||
|
|
||||||
test('IM settings renders three compact logo channel tabs without enable switches', () => {
|
test('IM settings renders three compact logo channel tabs without enable switches', () => {
|
||||||
|
|
@ -48,8 +50,7 @@ test('the DingTalk QR card stacks within the narrow combined-channel panel', asy
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the bundled DingTalk channel has no local sender approval workflow', async () => {
|
test('the bundled DingTalk channel has no local sender approval workflow', async () => {
|
||||||
const [{ DINGTALK_ENDPOINTS }, source, bundle] = await Promise.all([
|
const [source, bundle] = await Promise.all([
|
||||||
import('@xmanrui/dsh-dingtalk/client-api'),
|
|
||||||
readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'),
|
readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'),
|
||||||
readFile(CLIENT_BUNDLE_URL, 'utf8'),
|
readFile(CLIENT_BUNDLE_URL, 'utf8'),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue