mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
Add QQ bot channel
This commit is contained in:
parent
a422084eb5
commit
601271a7f8
33 changed files with 4971 additions and 642 deletions
32
README.md
32
README.md
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
## 中文
|
||||
|
||||
通过扫码把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信和钉钉机器人。
|
||||
通过扫码把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉和 QQ 机器人。
|
||||
|
||||
> GitHub 简介:通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉等)。
|
||||
> GitHub 简介:通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉和QQ)。
|
||||
|
||||
## 界面
|
||||
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
- 飞书:扫码创建并绑定机器人,使用长连接收发消息;
|
||||
- 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息;
|
||||
- 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。
|
||||
- QQ:使用手机 QQ 扫码创建或绑定 QQ 机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。
|
||||
|
||||
其他 IM 平台可继续按同一渠道适配器结构接入。
|
||||
|
||||
|
|
@ -26,15 +27,17 @@ npx -y github:xmanrui/dsh-im install
|
|||
|
||||
重启 `dsh web`,然后打开「设置 → 插件 → IM机器人」。安装器会用 `dsh-im` 替换 profile 中直接安装的 `dsh-feishu`、`dsh-weixin` 和 `dsh-dingtalk`,但不删除任何渠道数据;原有渠道凭据和扫码绑定会继续使用。
|
||||
|
||||
钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。钉钉当前可能在该官方授权页显示 OpenClaw 品牌;扫码后的机器人连接、凭据和消息均由 DeepSeek Harness 管理。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。
|
||||
钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。
|
||||
|
||||
QQ 接入使用腾讯 QQBot v2 官方扫码流程,不需要手动填写 AppID 或 AppSecret。默认腾讯授权页会把接入方显示为“第三方机器人”;扫码成功后创建或绑定的是 QQ 开放平台机器人,并不是让插件直接控制个人 QQ 账号。扫码者会自动成为该机器人在 Harness 中的使用者,不增加本机二次批准步骤。
|
||||
|
||||
## 设计
|
||||
|
||||
- Harness 中只注册一个「IM机器人」设置页;
|
||||
- 飞书、微信和钉钉的 Host、客户端与运行时源码都在本仓库维护,不依赖外部 `dsh-feishu`、`dsh-weixin` 或 `dsh-dingtalk` 插件;
|
||||
- 左侧使用渠道 Logo 切换微信、飞书和钉钉,不使用启用/停用开关;
|
||||
- 三个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
||||
- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret` 或原始 staff ID。
|
||||
- 飞书、微信、钉钉和 QQ 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
|
||||
- 左侧使用渠道 Logo 切换微信、飞书、钉钉和 QQ,不使用启用/停用开关;
|
||||
- 四个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
||||
- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret`、QQ `app_secret` 或原始用户标识。
|
||||
|
||||
## 本地开发
|
||||
|
||||
|
|
@ -50,9 +53,9 @@ node bin/dsh-im.mjs install --source .
|
|||
|
||||
## English
|
||||
|
||||
Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one settings entry provide unified management for Feishu, WeChat, and DingTalk bots.
|
||||
Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, and QQ bots.
|
||||
|
||||
> GitHub description: Connect IM bots to DeepSeek Harness by scanning a QR code (supports Feishu, WeChat, DingTalk, and more).
|
||||
> GitHub description: Connect IM bots to DeepSeek Harness by scanning a QR code (supports Feishu, WeChat, DingTalk, and QQ).
|
||||
|
||||
## Interface
|
||||
|
||||
|
|
@ -63,6 +66,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one se
|
|||
- Feishu: create and bind a bot by scanning a QR code, then send and receive messages over a persistent connection.
|
||||
- WeChat: bind a WeChat bot by scanning a QR code, then send and receive messages through Tencent iLink long polling.
|
||||
- DingTalk: create and authorize a bot by scanning a QR code, receive messages through DingTalk Stream, and stream Harness replies through AI Cards.
|
||||
- QQ: create or bind a QQ bot by scanning with mobile QQ, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned.
|
||||
|
||||
Other IM platforms can be added through the same channel-adapter structure.
|
||||
|
||||
|
|
@ -74,15 +78,17 @@ npx -y github:xmanrui/dsh-im install
|
|||
|
||||
Restart `dsh web`, then open **Settings → Plugins → IM Bot**. The installer replaces directly installed `dsh-feishu`, `dsh-weixin`, and `dsh-dingtalk` entries in the profile with `dsh-im` without deleting channel data.
|
||||
|
||||
For DingTalk, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. That DingTalk-hosted page may currently display OpenClaw branding; the resulting connection, credentials, and messages are managed by DeepSeek Harness. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members.
|
||||
For DingTalk, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members.
|
||||
|
||||
QQ uses Tencent's official QQBot v2 QR flow and does not require manually entering an AppID or AppSecret. Tencent's default authorization page labels the integration as a third-party bot. Scanning creates or binds a QQ Open Platform bot; it does not give the plugin direct control of a personal QQ account. The scanner becomes the Harness user for that bot without an additional local approval step.
|
||||
|
||||
## Design
|
||||
|
||||
- Registers a single **IM Bot** settings page in Harness.
|
||||
- Maintains the Feishu, WeChat, 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.
|
||||
- Maintains the Feishu, WeChat, DingTalk, and QQ Host, client, and runtime sources in this repository without external standalone channel plugins.
|
||||
- Uses channel logos for WeChat, Feishu, DingTalk, and QQ navigation without enable/disable switches.
|
||||
- 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`, QQ `app_secret`, or raw user identifiers.
|
||||
|
||||
## Local development
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ The Weixin iLink request format, QR-login states, and message fields are adapted
|
|||
|
||||
The DingTalk device-authorization request sequence and AI Card streaming protocol are adapted from DingTalk Real Team's [`dingtalk-openclaw-connector`](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector) project at commit `b2fd6e5ea2ff99bd213faac637d3da541b2bfaf4`, licensed under the MIT License and copyright 2026 DingTalk Real Team.
|
||||
|
||||
This package depends on [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. Each is licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队.
|
||||
This package depends on [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队.
|
||||
|
||||
This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, Feishu, or DingTalk.
|
||||
QQ QR binding uses Tencent Connect's official [`@tencent-connect/qqbot-connector`](https://www.npmjs.com/package/@tencent-connect/qqbot-connector) 1.2.0 package as an external runtime dependency. Its npm metadata declares `UNLICENSED`; no connector source is copied into this project.
|
||||
|
||||
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.
|
||||
This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, Feishu, DingTalk, or QQ.
|
||||
|
||||
The WeChat and QQ marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu and DingTalk marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners.
|
||||
|
|
|
|||
1071
lib/client.js
1071
lib/client.js
File diff suppressed because it is too large
Load diff
2083
lib/index.js
2083
lib/index.js
File diff suppressed because it is too large
Load diff
46
package-lock.json
generated
46
package-lock.json
generated
|
|
@ -10,6 +10,8 @@
|
|||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "1.73.0",
|
||||
"@tencent-connect/qqbot-connector": "1.2.0",
|
||||
"@tencent-connect/qqbot-nodejs": "1.0.4",
|
||||
"dingtalk-stream": "2.1.4",
|
||||
"qrcode": "1.5.4"
|
||||
},
|
||||
|
|
@ -540,6 +542,42 @@
|
|||
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@tencent-connect/qqbot-connector": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tencent-connect/qqbot-connector/-/qqbot-connector-1.2.0.tgz",
|
||||
"integrity": "sha512-FAOUCvgxP4M9UiYbHrtVgJ7BavSlh1CHJPjeEQuTAkP9MZ91lX4yATqv6I/lB9yp15nVq+G2DaGSSJwQTSoSsA==",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"qrcode-terminal": "^0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tencent-connect/qqbot-nodejs": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tencent-connect/qqbot-nodejs/-/qqbot-nodejs-1.0.4.tgz",
|
||||
"integrity": "sha512-gU5HySLplczZXMUjM7NtiUACY7YfX9YlI/R9PKzCLMgLmHvwsX9L2sitsrYPMentGUr9b8NLfSaSTsndF77NBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mpg123-decoder": "^1.0.3",
|
||||
"silk-wasm": "^3.7.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mpg123-decoder": {
|
||||
"optional": true
|
||||
},
|
||||
"silk-wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
|
|
@ -1235,6 +1273,14 @@
|
|||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-terminal": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
|
||||
"integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
|
||||
"bin": {
|
||||
"qrcode-terminal": "bin/qrcode-terminal.js"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@xmanrui/dsh-im",
|
||||
"version": "0.1.0",
|
||||
"description": "通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉等)。",
|
||||
"description": "通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉和QQ)。",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
|
@ -58,6 +58,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "1.73.0",
|
||||
"@tencent-connect/qqbot-connector": "1.2.0",
|
||||
"@tencent-connect/qqbot-nodejs": "1.0.4",
|
||||
"dingtalk-stream": "2.1.4",
|
||||
"qrcode": "1.5.4"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -44,3 +44,16 @@ export function DingtalkLogoGlyph({ size } = {}) {
|
|||
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',
|
||||
}));
|
||||
}
|
||||
|
||||
export function QqLogoGlyph({ size } = {}) {
|
||||
return h('svg', {
|
||||
...dimensions(size),
|
||||
viewBox: '0 0 24 24',
|
||||
focusable: 'false',
|
||||
'aria-hidden': 'true',
|
||||
'data-im-channel-logo': 'qq',
|
||||
}, h('path', {
|
||||
fill: 'currentColor',
|
||||
d: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673',
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
118
plugin-src/client/channels/qq/api.js
Normal file
118
plugin-src/client/channels/qq/api.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
export const QQ_RPC_CHANNEL = '/qq';
|
||||
|
||||
export const QQ_ENDPOINTS = Object.freeze({
|
||||
status: 'connection.status',
|
||||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
||||
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
|
||||
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
|
||||
const QR_DATA_URL = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i;
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function text(value, fallback, max = 240) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : fallback;
|
||||
}
|
||||
|
||||
function id(value) {
|
||||
const result = text(value, '', 128);
|
||||
return /^[a-z\d_-]+$/i.test(result) ? result : undefined;
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
export function unwrapRpcResult(result) {
|
||||
if (!isRecord(result) || typeof result.ok !== 'boolean') throw new Error('QQ 服务返回了无法识别的响应');
|
||||
if (!result.ok) {
|
||||
const error = new Error(text(result.error?.message, 'QQ 操作失败'));
|
||||
error.code = text(result.error?.code, 'QQ_RPC_ERROR', 80);
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export function safeQrSource(value) {
|
||||
return typeof value === 'string' && value.length <= 2 * 1024 * 1024 && QR_DATA_URL.test(value)
|
||||
? value : undefined;
|
||||
}
|
||||
|
||||
export function normalizeProvisioning(value, now = Date.now()) {
|
||||
const source = isRecord(value?.provisioning) ? value.provisioning : value;
|
||||
if (!isRecord(source)) throw new Error('QQ 服务没有返回扫码绑定进度');
|
||||
const attemptId = id(source.attemptId);
|
||||
if (!attemptId) throw new Error('QQ 扫码服务没有返回有效的绑定任务');
|
||||
const reported = text(source.status, 'failed', 32);
|
||||
const result = {
|
||||
attemptId,
|
||||
status: PROVISION_STATES.has(reported) ? reported : 'failed',
|
||||
expiresAt: timestamp(source.expiresAt) ?? now + 5 * 60_000,
|
||||
pollIntervalMs: Math.min(10_000, Math.max(500, Number(source.pollIntervalMs) || 1_000)),
|
||||
qrRevision: Number.isSafeInteger(source.qrRevision) ? source.qrRevision : 0,
|
||||
};
|
||||
const qrCodeDataUrl = safeQrSource(source.qrCodeDataUrl);
|
||||
if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl;
|
||||
if (id(source.botId)) result.botId = id(source.botId);
|
||||
if (isRecord(source.error)) result.error = {
|
||||
code: text(source.error.code, 'QQ_PROVISION_FAILED', 80),
|
||||
message: text(source.error.message, 'QQ 机器人没有接入完成'),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeBot(value) {
|
||||
if (!isRecord(value) || !id(value.botId)) return undefined;
|
||||
const connected = value.connected === true;
|
||||
const state = ACCOUNT_STATES.has(value.state) ? value.state : 'offline';
|
||||
return {
|
||||
botId: id(value.botId),
|
||||
connected,
|
||||
state: connected ? 'connected' : state,
|
||||
bot: {
|
||||
name: text(value.bot?.name, 'QQ机器人', 100),
|
||||
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),
|
||||
},
|
||||
health: {
|
||||
summary: text(value.health?.summary, connected ? 'QQ WebSocket 长连接运行正常' : 'QQ 连接尚未就绪'),
|
||||
lastCheckedAt: timestamp(value.health?.lastCheckedAt),
|
||||
},
|
||||
error: isRecord(value.error) ? {
|
||||
code: text(value.error.code, 'QQ_ACCOUNT_ERROR', 80),
|
||||
message: text(value.error.message, 'QQ 连接尚未就绪'),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSnapshot(value) {
|
||||
const source = isRecord(value?.snapshot) ? value.snapshot : value;
|
||||
if (!isRecord(source) || !Array.isArray(source.bots)) throw new Error('QQ 服务没有返回有效的机器人列表');
|
||||
const bots = source.bots.map(normalizeBot).filter(Boolean);
|
||||
return {
|
||||
revision: Number.isSafeInteger(source.revision) ? source.revision : 0,
|
||||
bots,
|
||||
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
|
||||
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function presentError(error) {
|
||||
return {
|
||||
code: text(error?.code, 'QQ_ERROR', 80),
|
||||
message: text(error?.message, 'QQ 操作失败,请稍后重试'),
|
||||
};
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
344
plugin-src/client/channels/qq/index.js
Normal file
344
plugin-src/client/channels/qq/index.js
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { QqLogoGlyph } from '../../channel-logos.js';
|
||||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
import {
|
||||
QQ_ENDPOINTS,
|
||||
QQ_RPC_CHANNEL,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
presentError,
|
||||
safeQrSource,
|
||||
unwrapRpcResult,
|
||||
} from './api.js';
|
||||
import { installQqStyles } from './styles.js';
|
||||
|
||||
const h = React.createElement;
|
||||
const ACTIVE_STATES = new Set(['pending', 'refreshing', 'connecting']);
|
||||
|
||||
const Button = React.forwardRef(function Button({ children, kind = 'secondary', className = '', ...props }, ref) {
|
||||
return h('button', {
|
||||
...props,
|
||||
ref,
|
||||
type: 'button',
|
||||
className: `ddt-button ${className}`.trim(),
|
||||
'data-kind': kind,
|
||||
}, children);
|
||||
});
|
||||
|
||||
function checkedTime(value) {
|
||||
if (!value) return '尚未检查';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
function Heading({ totals, adding, busy, onAdd, addButtonRef }) {
|
||||
return h('div', { className: 'ddt-heading' },
|
||||
h('div', { className: 'ddt-tools' },
|
||||
h(Button, {
|
||||
kind: 'primary',
|
||||
className: 'dim-scanButton',
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
}, adding ? '正在绑定' : '扫码绑定QQ机器人'),
|
||||
totals.configured > 0
|
||||
? h('div', { className: 'ddt-badge dim-onlineBadge' },
|
||||
h('span', null, `${totals.connected} / ${totals.configured} 在线`))
|
||||
: null));
|
||||
}
|
||||
|
||||
function LoadingView() {
|
||||
return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView', 'aria-busy': 'true' },
|
||||
h('div', { className: 'ddt-spinner dim-spinner' }),
|
||||
h('span', null, '正在读取 QQ 机器人状态…'));
|
||||
}
|
||||
|
||||
function EmptyView({ busy, onStart }) {
|
||||
return h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||
h('div', { className: 'ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView' },
|
||||
h('div', { className: 'dim-emptyCopy' },
|
||||
h('div', { className: 'ddt-stateLabel dim-stateLabel' },
|
||||
h('span', { className: 'ddt-dot dim-stateDot' }), h('span', null, '尚未绑定 QQ 机器人')),
|
||||
h('h3', null, '使用手机 QQ 扫码创建并绑定机器人'),
|
||||
h('p', null, '扫码由腾讯官方页面完成,不需要手动填写 AppID 或 AppSecret。扫码成功后,机器人会自动连接 DeepSeek Harness。'),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, { kind: 'primary', onClick: onStart, disabled: busy },
|
||||
busy ? '正在生成二维码…' : '生成 QQ 二维码'))),
|
||||
h('div', { className: 'ddt-brandMark dim-emptyBrand dqq-brand', 'aria-hidden': 'true' },
|
||||
h(QqLogoGlyph, { size: 64 }))));
|
||||
}
|
||||
|
||||
function QrPanel({ provision, now, busy, onRefresh, onCancel }) {
|
||||
const source = safeQrSource(provision.qrCodeDataUrl);
|
||||
const remaining = Math.max(0, provision.expiresAt - now);
|
||||
const duration = Math.max(1, provision.durationMs ?? 5 * 60_000);
|
||||
const progress = Math.round(Math.min(1, remaining / duration) * 100);
|
||||
const refreshing = provision.status === 'refreshing';
|
||||
return h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||
h('div', { className: 'ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout' },
|
||||
h('div', { className: 'ddt-qrColumn dim-qrColumn' },
|
||||
h('div', { className: 'ddt-qrFrame dim-qrFrame' },
|
||||
source ? h('img', { src: source, alt: '用于绑定 QQ 机器人的一次性二维码' })
|
||||
: h('div', { className: 'ddt-qrFallback dim-qrFallback' },
|
||||
refreshing ? '二维码正在自动刷新…' : '二维码图片正在生成…')),
|
||||
h('div', { className: 'ddt-countdown dim-countdown' },
|
||||
h('div', { className: 'ddt-countdownTop dim-countdownTop' },
|
||||
h('span', null, '当前二维码有效时间'),
|
||||
h('strong', null, refreshing ? '--:--' : formatRemaining(remaining))),
|
||||
h('div', { className: 'ddt-progress dim-progress', style: { '--ddt-progress': `${progress}%` } }, h('span')))),
|
||||
h('div', { className: 'ddt-qrCopy dim-qrCopy' },
|
||||
h('div', { className: 'ddt-stateLabel dim-stateLabel' },
|
||||
h('span', { className: 'ddt-dot dim-stateDot', 'data-tone': 'warning' }),
|
||||
h('span', null, refreshing ? '正在刷新二维码' : '等待手机 QQ 扫码')),
|
||||
h('h3', null, '使用手机 QQ 完成机器人绑定'),
|
||||
h('p', null, '腾讯页面会创建或绑定一个 QQ 机器人,并把连接凭据安全交给本机 Harness Host。'),
|
||||
h('ol', { className: 'ddt-steps dim-steps' },
|
||||
h('li', null, '打开手机 QQ,扫描左侧二维码'),
|
||||
h('li', null, '在腾讯授权页面确认创建或绑定机器人'),
|
||||
h('li', null, '返回这里等待连接完成')),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, { onClick: onRefresh, disabled: busy }, '重新生成二维码'),
|
||||
h(Button, { kind: 'quiet', onClick: onCancel, disabled: busy }, '取消')))));
|
||||
}
|
||||
|
||||
function ProvisionView({ provision, busy, onRetry, onClose }) {
|
||||
if (provision.status === 'connecting') {
|
||||
return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-specialView', 'aria-busy': 'true' },
|
||||
h('div', { className: 'ddt-spinner dim-spinner' }),
|
||||
h('h3', null, 'QQ 已授权,正在连接机器人'),
|
||||
h('p', null, '凭据正在写入本机,并启动 QQ WebSocket 消息连接。'));
|
||||
}
|
||||
const error = provision.error ?? { code: 'QQ_PROVISION_FAILED', message: 'QQ 机器人没有绑定完成' };
|
||||
return h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||
h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' },
|
||||
h('h3', null, 'QQ 机器人没有绑定完成'),
|
||||
h('p', null, error.message),
|
||||
h('span', { className: 'ddt-errorCode' }, error.code),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
|
||||
h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
|
||||
}
|
||||
|
||||
function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
|
||||
return h('div', { className: 'ddt-confirm dim-confirm', role: 'alertdialog' },
|
||||
h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`),
|
||||
h('p', null, '这会停止消息连接,并删除本机保存的应用凭据、机器人配置及会话映射。腾讯平台中的机器人不会被自动删除。'),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, { onClick: onCancel, disabled: busy }, '保留机器人'),
|
||||
h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入')));
|
||||
}
|
||||
|
||||
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
|
||||
const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
|
||||
return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId },
|
||||
h('div', { className: 'ddt-cardBody dim-botCardBody' },
|
||||
h('div', { className: 'ddt-accountTop dim-botCardTop' },
|
||||
h('div', { className: 'ddt-accountIdentity dim-botIdentity' },
|
||||
h('div', { className: 'ddt-avatar dim-botAvatar dqq-avatar', 'aria-hidden': 'true' }, h(QqLogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))),
|
||||
h('div', { className: 'ddt-health dim-botHealth' },
|
||||
h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), h('span', null, stateLabel))),
|
||||
h('dl', { className: 'ddt-metrics dim-botMetrics' },
|
||||
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')),
|
||||
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
h('div', { className: 'ddt-summary dim-cardSummary' }, account.error?.message ?? account.health.summary),
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
|
||||
removing ? h(RemoveConfirmation, {
|
||||
account, busy: busy === 'delete', onConfirm: onConfirmRemove, onCancel: onCancelRemove,
|
||||
}) : null);
|
||||
}
|
||||
|
||||
export function QqSettingsTab({ rpcCall }) {
|
||||
const [model, setModel] = React.useState({ phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null });
|
||||
const [provision, setProvision] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [now, setNow] = React.useState(Date.now());
|
||||
const mounted = React.useRef(true);
|
||||
const addButtonRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const disposeDingtalk = installDingtalkStyles();
|
||||
const disposeQq = installQqStyles();
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
disposeQq();
|
||||
disposeDingtalk();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
if (typeof rpcCall !== 'function') throw new TypeError('QQ 设置页缺少 RPC 连接');
|
||||
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||||
}, [rpcCall]);
|
||||
|
||||
const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
|
||||
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(QQ_ENDPOINTS.status, {}, signal));
|
||||
if (!mounted.current || signal?.aborted) return undefined;
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
if (restore && snapshot.provisioning) setProvision({
|
||||
...snapshot.provisioning,
|
||||
durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
|
||||
});
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
|
||||
setModel((current) => ({ ...current, phase: silent ? current.phase : 'error', error: presentError(error) }));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}, [invoke]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadStatus({ signal: controller.signal, restore: true });
|
||||
return () => controller.abort();
|
||||
}, [loadStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (model.phase !== 'ready') return undefined;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15_000);
|
||||
return () => { controller.abort(); window.clearInterval(timer); };
|
||||
}, [loadStatus, model.phase]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!provision || !ACTIVE_STATES.has(provision.status)) return undefined;
|
||||
const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [provision?.attemptId, provision?.status]);
|
||||
|
||||
const startProvisioning = React.useCallback(async (replace = false) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (replace && provision?.attemptId) await invoke(QQ_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
|
||||
if (!mounted.current) return;
|
||||
setProvision({ status: 'starting' });
|
||||
const started = normalizeProvisioning(await invoke(QQ_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' }));
|
||||
if (!mounted.current) return;
|
||||
setNow(Date.now());
|
||||
setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) });
|
||||
} catch (error) {
|
||||
if (mounted.current) setProvision({ status: 'failed', error: presentError(error) });
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [invoke, provision?.attemptId]);
|
||||
|
||||
const closeProvision = React.useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (provision?.attemptId && ACTIVE_STATES.has(provision.status)) {
|
||||
await invoke(QQ_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
|
||||
}
|
||||
if (mounted.current) setProvision(null);
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [invoke, provision?.attemptId, provision?.status]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const attemptId = provision?.attemptId;
|
||||
if (!attemptId || !ACTIVE_STATES.has(provision.status)) return undefined;
|
||||
const controller = new AbortController();
|
||||
let disposed = false;
|
||||
let timer;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const current = normalizeProvisioning(await invoke(QQ_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal));
|
||||
if (disposed || controller.signal.aborted || !mounted.current) return;
|
||||
if (current.status === 'connected') {
|
||||
setProvision(null);
|
||||
await loadStatus({ signal: controller.signal, silent: true });
|
||||
return;
|
||||
}
|
||||
setProvision((previous) => previous?.attemptId === attemptId
|
||||
? { ...previous, ...current, durationMs: current.qrRevision !== previous.qrRevision
|
||||
? Math.max(1, current.expiresAt - Date.now()) : previous.durationMs }
|
||||
: previous);
|
||||
if (ACTIVE_STATES.has(current.status)) timer = window.setTimeout(poll, current.pollIntervalMs);
|
||||
} catch (error) {
|
||||
if (!disposed && !controller.signal.aborted && mounted.current) {
|
||||
setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
|
||||
}
|
||||
}
|
||||
};
|
||||
timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000);
|
||||
return () => { disposed = true; controller.abort(); window.clearTimeout(timer); };
|
||||
}, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]);
|
||||
|
||||
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
|
||||
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
|
||||
if (mounted.current) setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
return snapshot;
|
||||
} finally {
|
||||
if (mounted.current) setBusyByBot((current) => {
|
||||
const next = { ...current }; delete next[account.botId]; return next;
|
||||
});
|
||||
}
|
||||
}, [invoke]);
|
||||
|
||||
let provisionView = null;
|
||||
if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请 QQ 二维码…');
|
||||
else if (['pending', 'refreshing'].includes(provision?.status)) provisionView = h(QrPanel, {
|
||||
provision, now, busy, onRefresh: () => void startProvisioning(true), onCancel: () => void closeProvision(),
|
||||
});
|
||||
else if (provision) provisionView = h(ProvisionView, {
|
||||
provision, busy, onRetry: () => void startProvisioning(true), onClose: () => void closeProvision(),
|
||||
});
|
||||
|
||||
const botList = model.bots.length > 0
|
||||
? h('section', { className: 'dim-listSection' },
|
||||
h('div', { className: 'ddt-listHeading dim-listHeading' },
|
||||
h('h3', null, '已绑定的 QQ 机器人'), h('span', null, `${model.bots.length} 个`)),
|
||||
h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
|
||||
h('li', { key: account.botId }, h(AccountCard, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(account, 'reconnect', QQ_ENDPOINTS.reconnectBot, { botId: account.botId }),
|
||||
onRequestRemove: () => setRemoveTarget(account.botId),
|
||||
onCancelRemove: () => setRemoveTarget(null),
|
||||
onConfirmRemove: async () => {
|
||||
await botAction(account, 'delete', QQ_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true });
|
||||
if (mounted.current) setRemoveTarget(null);
|
||||
},
|
||||
})))))
|
||||
: null;
|
||||
|
||||
return h('section', { className: 'ddt-page dqq-page dim-channelPage', 'aria-label': 'QQ 设置' },
|
||||
h(Heading, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }),
|
||||
model.phase === 'loading' ? h(LoadingView)
|
||||
: model.phase === 'error'
|
||||
? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取 QQ 机器人状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取')))
|
||||
: h(React.Fragment, null,
|
||||
provisionView,
|
||||
model.bots.length === 0 && !provision ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
|
||||
botList));
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(() => installQqStyles(), 'qq-settings: install client styles');
|
||||
const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal);
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab', id: 'qq', order: 50, label: 'QQ', inject: () => ({ rpcCall }),
|
||||
}, QqSettingsTab));
|
||||
}
|
||||
19
plugin-src/client/channels/qq/styles.js
Normal file
19
plugin-src/client/channels/qq/styles.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const QQ_STYLE_ID = 'xmanrui-dsh-im-qq-settings';
|
||||
|
||||
const CSS = String.raw`
|
||||
.dqq-page { --ddt-accent: #1677ff; --ddt-accent-deep: #0958d9; --ddt-accent-wash: #eaf3ff; }
|
||||
.dqq-avatar, .dqq-brand { color: #fff; background: #1677ff; }
|
||||
.dqq-avatar svg, .dqq-brand svg { display: block; }
|
||||
`;
|
||||
|
||||
export function installQqStyles() {
|
||||
if (typeof document === 'undefined') return () => {};
|
||||
const existing = document.querySelector(`style[data-plugin-css="${QQ_STYLE_ID}"]`);
|
||||
if (existing) return () => {};
|
||||
const style = document.createElement('style');
|
||||
style.dataset.plugin = '@xmanrui/dsh-im';
|
||||
style.dataset.pluginCss = QQ_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { DingtalkLogoGlyph, FeishuLogoGlyph, WeixinLogoGlyph } from './channel-logos.js';
|
||||
import { DingtalkLogoGlyph, FeishuLogoGlyph, QqLogoGlyph, WeixinLogoGlyph } from './channel-logos.js';
|
||||
import { DINGTALK_RPC_CHANNEL } from './channels/dingtalk/api.js';
|
||||
import { DingtalkSettingsTab } from './channels/dingtalk/index.js';
|
||||
import { FeishuSettingsTab } from './channels/feishu/index.js';
|
||||
import { FEISHU_RPC_CHANNEL } from './channels/feishu/api.js';
|
||||
import { installFeishuStyles } from './channels/feishu/styles.js';
|
||||
import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
|
||||
import { QqSettingsTab } from './channels/qq/index.js';
|
||||
import { installQqStyles } from './channels/qq/styles.js';
|
||||
import { WeixinSettingsTab } from './channels/weixin/index.js';
|
||||
import { WEIXIN_RPC_CHANNEL } from './channels/weixin/api.js';
|
||||
import { installWeixinStyles } from './channels/weixin/styles.js';
|
||||
|
|
@ -20,6 +23,7 @@ const CHANNELS = Object.freeze([
|
|||
{ id: 'weixin', label: '微信' },
|
||||
{ id: 'feishu', label: '飞书' },
|
||||
{ id: 'dingtalk', label: '钉钉' },
|
||||
{ id: 'qq', label: 'QQ' },
|
||||
]);
|
||||
|
||||
function WeixinLogo() {
|
||||
|
|
@ -37,13 +41,18 @@ function DingtalkLogo() {
|
|||
h(DingtalkLogoGlyph));
|
||||
}
|
||||
|
||||
function QqLogo() {
|
||||
return h('span', { className: 'dim-logo dim-logoQq', 'aria-hidden': 'true' }, h(QqLogoGlyph));
|
||||
}
|
||||
|
||||
function ChannelLogo({ channel }) {
|
||||
if (channel === 'weixin') return h(WeixinLogo);
|
||||
if (channel === 'feishu') return h(FeishuLogo);
|
||||
return h(DingtalkLogo);
|
||||
if (channel === 'dingtalk') return h(DingtalkLogo);
|
||||
return h(QqLogo);
|
||||
}
|
||||
|
||||
export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, weixinRpcCall }) {
|
||||
export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }) {
|
||||
const [selected, setSelected] = React.useState('weixin');
|
||||
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
|
||||
return h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
|
||||
|
|
@ -76,14 +85,16 @@ export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, weixinRpcCall })
|
|||
? h(WeixinSettingsTab, { rpcCall: weixinRpcCall })
|
||||
: active.id === 'feishu'
|
||||
? h(FeishuSettingsTab, { rpcCall: feishuRpcCall })
|
||||
: h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall })),
|
||||
: active.id === 'dingtalk'
|
||||
? h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall })
|
||||
: h(QqSettingsTab, { rpcCall: qqRpcCall })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(() => {
|
||||
const disposers = [installFeishuStyles(), installWeixinStyles(), installImStyles()];
|
||||
const disposers = [installFeishuStyles(), installWeixinStyles(), installQqStyles(), installImStyles()];
|
||||
return () => {
|
||||
for (const dispose of disposers.reverse()) dispose();
|
||||
};
|
||||
|
|
@ -95,12 +106,14 @@ export function apply(ctx) {
|
|||
ctx.connection.rpc.call(WEIXIN_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const dingtalkRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(DINGTALK_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const qqRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal);
|
||||
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'im',
|
||||
order: 20,
|
||||
label: 'IM机器人',
|
||||
inject: () => ({ dingtalkRpcCall, feishuRpcCall, weixinRpcCall }),
|
||||
inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }),
|
||||
}, IMSettingsTab));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,13 @@ const CSS = String.raw`
|
|||
.dim-logoFeishu svg { width: 22px; height: 22px; }
|
||||
.dim-logoDingtalk { color: white; background: #1677ff; }
|
||||
.dim-logoDingtalk svg { width: 24px; height: 24px; }
|
||||
.dim-logoQq { color: white; background: #1677ff; }
|
||||
.dim-logoQq svg { width: 21px; height: 21px; }
|
||||
.dim-channelCopy { min-width: 0; display: block; }
|
||||
.dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-line-divider, #eef0f3); }
|
||||
.dim-panel { min-width: 0; container-type: inline-size; }
|
||||
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page { width: 100%; max-width: none; padding: 0 0 24px; }
|
||||
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page { width: 100%; max-width: none; padding: 0 0 24px; }
|
||||
.dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; }
|
||||
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; justify-content: space-between; }
|
||||
.dim-panel .bxf-headingTools .dim-scanButton, .dim-panel .dxw-tools .dim-scanButton, .dim-panel .ddt-tools .dim-scanButton { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; box-shadow: none; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; }
|
||||
|
|
|
|||
124
plugin-src/host/channels/qq/connection-supervisor.mjs
Normal file
124
plugin-src/host/channels/qq/connection-supervisor.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]);
|
||||
|
||||
function retryDelays(value) {
|
||||
if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS];
|
||||
const valid = value.filter((delay) => Number.isFinite(delay) && delay >= 0);
|
||||
return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS];
|
||||
}
|
||||
|
||||
export class ConnectionSupervisor {
|
||||
#controller;
|
||||
#harness;
|
||||
#logger;
|
||||
#retryDelays;
|
||||
#healthyIntervalMs;
|
||||
#setTimeout;
|
||||
#clearTimeout;
|
||||
#timer = null;
|
||||
#running = null;
|
||||
#retryIndex = 0;
|
||||
#closed = false;
|
||||
#started = false;
|
||||
#ready;
|
||||
#resolveReady;
|
||||
|
||||
constructor({
|
||||
controller,
|
||||
harness,
|
||||
logger = console,
|
||||
retryDelaysMs,
|
||||
healthyIntervalMs = 15_000,
|
||||
setTimeoutImpl = setTimeout,
|
||||
clearTimeoutImpl = clearTimeout,
|
||||
}) {
|
||||
if (!controller || typeof controller.initialize !== 'function' || typeof controller.status !== 'function') {
|
||||
throw new TypeError('ConnectionSupervisor requires a controller');
|
||||
}
|
||||
if (!harness || typeof harness.ensureRunning !== 'function') {
|
||||
throw new TypeError('ConnectionSupervisor requires a Harness client');
|
||||
}
|
||||
this.#controller = controller;
|
||||
this.#harness = harness;
|
||||
this.#logger = logger;
|
||||
this.#retryDelays = retryDelays(retryDelaysMs);
|
||||
this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0
|
||||
? healthyIntervalMs : 15_000;
|
||||
this.#setTimeout = setTimeoutImpl;
|
||||
this.#clearTimeout = clearTimeoutImpl;
|
||||
this.#ready = new Promise((resolve) => {
|
||||
this.#resolveReady = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
get ready() {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.#started || this.#closed) return this;
|
||||
this.#started = true;
|
||||
this.#schedule(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
if (this.#timer !== null) this.#clearTimeout(this.#timer);
|
||||
this.#timer = null;
|
||||
await this.#running?.catch(() => undefined);
|
||||
this.#resolveReady?.(null);
|
||||
this.#resolveReady = null;
|
||||
}
|
||||
|
||||
#schedule(delayMs) {
|
||||
if (this.#closed) return;
|
||||
this.#timer = this.#setTimeout(() => {
|
||||
this.#timer = null;
|
||||
void this.#run();
|
||||
}, delayMs);
|
||||
this.#timer?.unref?.();
|
||||
}
|
||||
|
||||
async #run() {
|
||||
if (this.#closed || this.#running) return;
|
||||
const operation = this.#reconcile();
|
||||
this.#running = operation;
|
||||
try {
|
||||
await operation;
|
||||
} finally {
|
||||
if (this.#running === operation) this.#running = null;
|
||||
}
|
||||
}
|
||||
|
||||
async #reconcile() {
|
||||
try {
|
||||
await this.#harness.ensureRunning();
|
||||
if (this.#closed) return;
|
||||
const status = await this.#controller.initialize();
|
||||
if (this.#closed) return;
|
||||
this.#resolveReady?.(status);
|
||||
this.#resolveReady = null;
|
||||
const { configured, connected } = status.totals;
|
||||
if (connected < configured) {
|
||||
const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
|
||||
this.#retryIndex += 1;
|
||||
this.#logger.warn?.(`[dsh-im:qq] ${connected}/${configured} bots connected; retrying in ${delay}ms`);
|
||||
this.#schedule(delay);
|
||||
return;
|
||||
}
|
||||
this.#retryIndex = 0;
|
||||
this.#schedule(this.#healthyIntervalMs);
|
||||
} catch (error) {
|
||||
if (this.#closed) return;
|
||||
const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
|
||||
this.#retryIndex += 1;
|
||||
this.#logger.warn?.(`[dsh-im:qq] connection reconciliation failed; retrying in ${delay}ms`, error);
|
||||
this.#schedule(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createConnectionSupervisor(options) {
|
||||
return new ConnectionSupervisor(options);
|
||||
}
|
||||
23
plugin-src/host/channels/qq/index.mjs
Normal file
23
plugin-src/host/channels/qq/index.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createProductionController } from './production.mjs';
|
||||
import { installQqRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-qq-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) return installQqRpc(ctx, config.controller, config.rpcOptions);
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const disposeRpc = installQqRpc(ctx, production.controller, config.rpcOptions);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close QQ bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
export function createQqHostPlugin(config) {
|
||||
return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) });
|
||||
}
|
||||
|
||||
export { createConnectionSupervisor, ConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
export { createProductionController } from './production.mjs';
|
||||
export { QQ_ENDPOINTS, QQ_RPC_CHANNEL, QQ_RPC_ENDPOINTS, createQqRpcHandler, installQqRpc } from './rpc.mjs';
|
||||
export { QqController } from '../../../../src/channels/qq/qq-controller.mjs';
|
||||
export { QqRuntime } from '../../../../src/channels/qq/qq-runtime.mjs';
|
||||
109
plugin-src/host/channels/qq/production.mjs
Normal file
109
plugin-src/host/channels/qq/production.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { unlink } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { QqConfigStore } from '../../../../src/channels/qq/config-store.mjs';
|
||||
import { QqHarnessClient } from '../../../../src/channels/qq/harness-client.mjs';
|
||||
import { QqController } from '../../../../src/channels/qq/qq-controller.mjs';
|
||||
import { QqRuntime } from '../../../../src/channels/qq/qq-runtime.mjs';
|
||||
import { QqQrAuth } from '../../../../src/channels/qq/qr-auth.mjs';
|
||||
import { QqStateStore } from '../../../../src/channels/qq/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-im QQ 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-qq'));
|
||||
return {
|
||||
config: resolve(config.configPath ?? join(root, 'config.json')),
|
||||
bots: resolve(config.botsDir ?? join(root, 'bots')),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||
if (!ctx?.credentials) throw new TypeError('dsh-im QQ requires ctx.credentials');
|
||||
if (!ctx?.webServer) throw new TypeError('dsh-im QQ requires ctx.webServer');
|
||||
|
||||
const ConfigStore = internals.ConfigStore ?? QqConfigStore;
|
||||
const StateStore = internals.StateStore ?? QqStateStore;
|
||||
const Harness = internals.HarnessClient ?? QqHarnessClient;
|
||||
const Controller = internals.Controller ?? QqController;
|
||||
const Runtime = internals.Runtime ?? QqRuntime;
|
||||
const QrAuth = internals.QrAuth ?? QqQrAuth;
|
||||
const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor;
|
||||
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:qq') : (ctx.logger ?? console);
|
||||
const paths = pluginPaths(config);
|
||||
const configStore = await new ConfigStore(paths.config).load();
|
||||
const qrAuth = internals.qrAuth ?? new QrAuth({ source: config.qrSource ?? 'deepseek-harness' });
|
||||
const stateStores = new Map();
|
||||
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
|
||||
const stateFor = async (botId) => {
|
||||
let state = stateStores.get(botId);
|
||||
if (!state) {
|
||||
state = await new StateStore(statePath(botId)).load();
|
||||
stateStores.set(botId, state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
const harness = new Harness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: resolve(config.workspace ?? process.cwd()),
|
||||
agentPreset: config.agentPreset ?? 'standard',
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
});
|
||||
const controller = new Controller({
|
||||
qrAuth,
|
||||
credentials: ctx.credentials,
|
||||
configStore,
|
||||
logger,
|
||||
createRuntime: async ({ botId, config: botConfig, appSecret }) => new Runtime({
|
||||
config: botConfig,
|
||||
appSecret,
|
||||
harness,
|
||||
state: await stateFor(botId),
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
connectTimeoutMs: config.connectTimeoutMs ?? 20_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') return state.remove();
|
||||
try {
|
||||
await unlink(statePath(botId));
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
controller,
|
||||
harness,
|
||||
logger,
|
||||
retryDelaysMs: config.retryDelaysMs,
|
||||
healthyIntervalMs: config.healthyIntervalMs,
|
||||
}).start();
|
||||
return {
|
||||
controller,
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
await controller.close();
|
||||
harness.stopManagedProcess();
|
||||
},
|
||||
};
|
||||
}
|
||||
128
plugin-src/host/channels/qq/rpc.mjs
Normal file
128
plugin-src/host/channels/qq/rpc.mjs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import QRCode from 'qrcode';
|
||||
|
||||
export const QQ_RPC_CHANNEL = '/qq';
|
||||
export const QQ_ENDPOINTS = Object.freeze({
|
||||
status: 'connection.status',
|
||||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
export const QQ_RPC_ENDPOINTS = Object.freeze(Object.values(QQ_ENDPOINTS));
|
||||
|
||||
const FORBIDDEN_PUBLIC_KEYS = new Set([
|
||||
'appSecret', 'app_secret', 'secretRef', 'ownerUserOpenid', 'userOpenid', 'verificationUrl',
|
||||
]);
|
||||
|
||||
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 === QQ_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
|
||||
if (endpoint === QQ_ENDPOINTS.beginProvisioning) {
|
||||
return exactKeys(payload, ['locale']) && (payload.locale === undefined || payload.locale === 'zh-CN')
|
||||
? null : 'provision.begin received unsupported fields.';
|
||||
}
|
||||
if ([QQ_ENDPOINTS.pollProvisioning, QQ_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
|
||||
return exactKeys(payload, ['attemptId']) && validId(payload.attemptId)
|
||||
? null : `${endpoint} requires an attemptId.`;
|
||||
}
|
||||
if (endpoint === QQ_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
|
||||
}
|
||||
if (endpoint === QQ_ENDPOINTS.deleteBot) {
|
||||
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
|
||||
? null : 'bot.delete requires a botId and confirm=true.';
|
||||
}
|
||||
return 'Unknown QQ endpoint.';
|
||||
}
|
||||
|
||||
function sanitizePublic(value) {
|
||||
if (Array.isArray(value)) return value.map(sanitizePublic);
|
||||
if (!isRecord(value)) return value;
|
||||
const safe = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
async function qrDataUrl(value) {
|
||||
return QRCode.toDataURL(value, {
|
||||
type: 'image/png', errorCorrectionLevel: 'M', margin: 2, width: 320,
|
||||
});
|
||||
}
|
||||
|
||||
async function withEncodedQr(value, encodeQr) {
|
||||
if (!value || typeof value.verificationUrl !== 'string') return sanitizePublic(value);
|
||||
return sanitizePublic({ ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) });
|
||||
}
|
||||
|
||||
async function publicStatus(status, encodeQr) {
|
||||
const value = structuredClone(status);
|
||||
if (value?.provisioning) value.provisioning = await withEncodedQr(value.provisioning, encodeQr);
|
||||
return sanitizePublic(value);
|
||||
}
|
||||
|
||||
export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
||||
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
|
||||
if (typeof controller?.[method] !== 'function') throw new TypeError(`A complete QQ controller is required (${method})`);
|
||||
}
|
||||
const qrCache = new Map();
|
||||
const cachedEncode = (url) => {
|
||||
let encoded = qrCache.get(url);
|
||||
if (!encoded) {
|
||||
if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value);
|
||||
encoded = Promise.resolve().then(() => encodeQr(url));
|
||||
qrCache.set(url, encoded);
|
||||
}
|
||||
return encoded;
|
||||
};
|
||||
return async (endpoint, payload, signal) => {
|
||||
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
|
||||
if (!QQ_RPC_ENDPOINTS.includes(endpoint)) return { ok: false, error: { code: 'bad-request', message: 'Unknown QQ endpoint.' } };
|
||||
const invalid = payloadFailure(endpoint, payload);
|
||||
if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } };
|
||||
try {
|
||||
let value;
|
||||
if (endpoint === QQ_ENDPOINTS.status) value = await publicStatus(await controller.status(), cachedEncode);
|
||||
else if (endpoint === QQ_ENDPOINTS.beginProvisioning) value = await withEncodedQr(await controller.startProvisioning(), cachedEncode);
|
||||
else if (endpoint === QQ_ENDPOINTS.pollProvisioning) {
|
||||
const current = await controller.registrationStatus(payload.attemptId);
|
||||
if (!current) return { ok: false, error: { code: 'bad-request', message: 'The provisioning attempt no longer exists.' } };
|
||||
value = await withEncodedQr(current, cachedEncode);
|
||||
} else if (endpoint === QQ_ENDPOINTS.cancelProvisioning) {
|
||||
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
|
||||
} else if (endpoint === QQ_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
} else {
|
||||
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
|
||||
}
|
||||
return signal?.aborted
|
||||
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
|
||||
: { ok: true, value };
|
||||
} catch {
|
||||
return signal?.aborted
|
||||
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
|
||||
: { ok: false, error: { code: 'qq-operation-failed', message: 'QQ 操作失败,请稍后重试。' } };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function installQqRpc(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(QQ_RPC_CHANNEL, createQqRpcHandler(controller, options), { authority: 'loopback' });
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
|
||||
import { apply as applyFeishu } from './channels/feishu/index.mjs';
|
||||
import { apply as applyQq } from './channels/qq/index.mjs';
|
||||
import { apply as applyWeixin } from './channels/weixin/index.mjs';
|
||||
|
||||
export const name = 'dsh-im-host';
|
||||
|
|
@ -9,6 +10,7 @@ export function createImHostPlugin(internals = {}) {
|
|||
const startFeishu = internals.applyFeishu ?? applyFeishu;
|
||||
const startWeixin = internals.applyWeixin ?? applyWeixin;
|
||||
const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
|
||||
const startQq = internals.applyQq ?? applyQq;
|
||||
return Object.freeze({
|
||||
name,
|
||||
inject,
|
||||
|
|
@ -16,6 +18,7 @@ export function createImHostPlugin(internals = {}) {
|
|||
await startFeishu(ctx, config.feishu ?? {});
|
||||
await startWeixin(ctx, config.weixin ?? {});
|
||||
await startDingtalk(ctx, config.dingtalk ?? {});
|
||||
await startQq(ctx, config.qq ?? {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ const required = [
|
|||
'plugin-src/host/channels/feishu/index.mjs',
|
||||
'plugin-src/host/channels/weixin/index.mjs',
|
||||
'plugin-src/host/channels/dingtalk/index.mjs',
|
||||
'plugin-src/host/channels/qq/index.mjs',
|
||||
'src/channels/feishu/feishu-runtime.mjs',
|
||||
'src/channels/weixin/weixin-runtime.mjs',
|
||||
'src/channels/dingtalk/dingtalk-runtime.mjs',
|
||||
'src/channels/qq/qq-runtime.mjs',
|
||||
];
|
||||
await Promise.all(required.map((path) => access(resolve(root, path))));
|
||||
|
||||
|
|
@ -47,7 +49,7 @@ if (!client.includes('container-type: inline-size')
|
|||
|| !client.includes('@container (max-width: 680px)')) {
|
||||
throw new Error('client bundle does not contain the narrow-panel DingTalk QR layout');
|
||||
}
|
||||
for (const marker of ['/feishu', '/weixin', '/dingtalk']) {
|
||||
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/qq']) {
|
||||
if (!host.includes(marker)) {
|
||||
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
|
||||
}
|
||||
|
|
@ -71,6 +73,8 @@ for (const name of ['@xmanrui/dsh-feishu', '@xmanrui/dsh-weixin', '@xmanrui/dsh-
|
|||
const directDependencies = {
|
||||
'@larksuiteoapi/node-sdk': '1.73.0',
|
||||
'dingtalk-stream': '2.1.4',
|
||||
'@tencent-connect/qqbot-connector': '1.2.0',
|
||||
'@tencent-connect/qqbot-nodejs': '1.0.4',
|
||||
qrcode: '1.5.4',
|
||||
};
|
||||
for (const [name, version] of Object.entries(directDependencies)) {
|
||||
|
|
|
|||
171
src/channels/qq/config-store.mjs
Normal file
171
src/channels/qq/config-store.mjs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function safeBotId(value) {
|
||||
const id = cleanString(value);
|
||||
return id && /^qq_[a-f0-9]{24}$/.test(id) ? id : null;
|
||||
}
|
||||
|
||||
function safeSecretRef(value) {
|
||||
const ref = cleanString(value);
|
||||
return ref && /^DSH_QQBOT_APP_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null;
|
||||
}
|
||||
|
||||
export function deriveQqBotIdentity(appId) {
|
||||
const raw = cleanString(appId);
|
||||
if (!raw) throw new TypeError('appId is required');
|
||||
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
||||
return {
|
||||
botId: `qq_${digest}`,
|
||||
secretRef: `DSH_QQBOT_APP_SECRET_${digest.toUpperCase()}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function maskQqAppId(appId) {
|
||||
const value = cleanString(appId) ?? '';
|
||||
if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : 'QQ机器人';
|
||||
return `${value.slice(0, 6)}••••${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
function normalizeBot(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const appId = cleanString(value.appId);
|
||||
const ownerUserOpenid = cleanString(value.ownerUserOpenid);
|
||||
const botId = safeBotId(value.botId);
|
||||
const secretRef = safeSecretRef(value.secretRef);
|
||||
if (!appId || !ownerUserOpenid || !botId || !secretRef) return null;
|
||||
const derived = deriveQqBotIdentity(appId);
|
||||
if (derived.botId !== botId || derived.secretRef !== secretRef) return null;
|
||||
return Object.freeze({
|
||||
botId,
|
||||
appId,
|
||||
secretRef,
|
||||
ownerUserOpenid,
|
||||
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
|
||||
connectedAt: cleanString(value.connectedAt),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDocument(value) {
|
||||
if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null;
|
||||
const bots = value.bots.map(normalizeBot);
|
||||
if (bots.some((bot) => bot === null)) return null;
|
||||
const ids = new Set();
|
||||
const appIds = new Set();
|
||||
const refs = new Set();
|
||||
for (const bot of bots) {
|
||||
if (ids.has(bot.botId) || appIds.has(bot.appId) || refs.has(bot.secretRef)) return null;
|
||||
ids.add(bot.botId);
|
||||
appIds.add(bot.appId);
|
||||
refs.add(bot.secretRef);
|
||||
}
|
||||
return Object.freeze({ version: 1, bots: Object.freeze(bots) });
|
||||
}
|
||||
|
||||
export class QqConfigStore {
|
||||
#path;
|
||||
#value = EMPTY_DOCUMENT;
|
||||
#writeQueue = Promise.resolve();
|
||||
|
||||
constructor(path) {
|
||||
this.#path = path;
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
|
||||
if (!normalized) throw new Error('dsh-im QQ config contains invalid bot data');
|
||||
this.#value = normalized;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
this.#value = EMPTY_DOCUMENT;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
list() {
|
||||
return structuredClone(this.#value.bots);
|
||||
}
|
||||
|
||||
get(botId) {
|
||||
const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
|
||||
return bot ? structuredClone(bot) : null;
|
||||
}
|
||||
|
||||
getByAppId(appId) {
|
||||
const bot = this.#value.bots.find((candidate) => candidate.appId === appId);
|
||||
return bot ? structuredClone(bot) : null;
|
||||
}
|
||||
|
||||
async save(value) {
|
||||
const normalized = normalizeBot(value);
|
||||
if (!normalized) throw new Error('Refusing to persist incomplete QQ bot data');
|
||||
return this.#mutate((bots) => {
|
||||
const appCollision = bots.find(
|
||||
(bot) => bot.appId === normalized.appId && bot.botId !== normalized.botId,
|
||||
);
|
||||
const refCollision = bots.find(
|
||||
(bot) => bot.secretRef === normalized.secretRef && bot.botId !== normalized.botId,
|
||||
);
|
||||
if (appCollision || refCollision) throw new Error('Duplicate QQ bot identity');
|
||||
const index = bots.findIndex((bot) => bot.botId === normalized.botId);
|
||||
if (index === -1) bots.push(normalized);
|
||||
else bots[index] = normalized;
|
||||
return structuredClone(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
async remove(botId) {
|
||||
if (!safeBotId(botId)) throw new TypeError('Invalid QQ bot id');
|
||||
return this.#mutate((bots) => {
|
||||
const index = bots.findIndex((bot) => bot.botId === botId);
|
||||
if (index === -1) return null;
|
||||
const [removed] = bots.splice(index, 1);
|
||||
return structuredClone(removed);
|
||||
});
|
||||
}
|
||||
|
||||
async clear() {
|
||||
const operation = this.#writeQueue.then(async () => {
|
||||
try {
|
||||
await unlink(this.#path);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
this.#value = EMPTY_DOCUMENT;
|
||||
});
|
||||
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
||||
await operation;
|
||||
}
|
||||
|
||||
async #mutate(mutator) {
|
||||
let result;
|
||||
const operation = this.#writeQueue.then(async () => {
|
||||
const bots = [...this.#value.bots];
|
||||
result = mutator(bots);
|
||||
const document = Object.freeze({ version: 1, bots: Object.freeze(bots) });
|
||||
await this.#write(document);
|
||||
this.#value = document;
|
||||
});
|
||||
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
||||
await operation;
|
||||
return result;
|
||||
}
|
||||
|
||||
async #write(document) {
|
||||
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.#path}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
await rename(temporary, this.#path);
|
||||
}
|
||||
}
|
||||
3
src/channels/qq/harness-client.mjs
Normal file
3
src/channels/qq/harness-client.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { HarnessClient } from '../weixin/harness-client.mjs';
|
||||
|
||||
export class QqHarnessClient extends HarnessClient {}
|
||||
177
src/channels/qq/qq-bridge.mjs
Normal file
177
src/channels/qq/qq-bridge.mjs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
const HELP_TEXT = [
|
||||
'QQ 机器人已连接 DeepSeek Harness。',
|
||||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
||||
function conversationKey(message) {
|
||||
return `${message.kind}:${message.kind === 'group' ? message.groupOpenid : message.senderId}`;
|
||||
}
|
||||
|
||||
function safeText(message) {
|
||||
return typeof message?.content === 'string' ? message.content.trim() : '';
|
||||
}
|
||||
|
||||
export function createQqBridgeStatus() {
|
||||
return {
|
||||
messagesReceived: 0,
|
||||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
export class QqHarnessBridge {
|
||||
#bot;
|
||||
#ownerUserOpenid;
|
||||
#harness;
|
||||
#state;
|
||||
#status;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#queues = new Map();
|
||||
|
||||
constructor({
|
||||
bot,
|
||||
ownerUserOpenid,
|
||||
harness,
|
||||
state,
|
||||
status = createQqBridgeStatus(),
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
}) {
|
||||
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
|
||||
if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
|
||||
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
||||
this.#bot = bot;
|
||||
this.#ownerUserOpenid = ownerUserOpenid;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#status = status;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
}
|
||||
|
||||
get status() {
|
||||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
accept(message) {
|
||||
const key = conversationKey(message);
|
||||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||
const current = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.#process(message))
|
||||
.finally(() => {
|
||||
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
||||
});
|
||||
this.#queues.set(key, current);
|
||||
return current;
|
||||
}
|
||||
|
||||
async waitForIdle() {
|
||||
await Promise.allSettled([...this.#queues.values()]);
|
||||
}
|
||||
|
||||
async #process(message) {
|
||||
const messageId = typeof message?.messageId === 'string' ? message.messageId : '';
|
||||
const sender = typeof message?.senderId === 'string' ? message.senderId : '';
|
||||
if (!messageId || !sender || message.senderIsBot === true) return;
|
||||
if (!['c2c', 'group'].includes(message.kind) || this.#state.hasSeen(messageId)) return;
|
||||
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (sender !== this.#ownerUserOpenid) {
|
||||
this.#status.messagesRejected += 1;
|
||||
this.#status.lastRejectedAt = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
|
||||
|
||||
const target = message.replyTarget;
|
||||
const text = safeText(message);
|
||||
try {
|
||||
if (!text) {
|
||||
await this.#bot.sendText(target, '目前仅支持文字消息。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const command = text.toLowerCase();
|
||||
if (command === '/help') {
|
||||
await this.#bot.sendText(target, HELP_TEXT);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (command === '/status') {
|
||||
await this.#harness.ensureRunning();
|
||||
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const key = conversationKey(message);
|
||||
if (command === '/new') {
|
||||
await this.#state.clearSession(key);
|
||||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId = this.#state.sessionFor(key);
|
||||
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
|
||||
sessionId = await this.#harness.createSession();
|
||||
await this.#state.setSession(key, sessionId);
|
||||
}
|
||||
|
||||
let stream = null;
|
||||
let streamFinished = false;
|
||||
if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
|
||||
try {
|
||||
stream = this.#bot.openStream({ target });
|
||||
} catch (error) {
|
||||
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error);
|
||||
}
|
||||
}
|
||||
const answer = await this.#harness.ask(sessionId, text, {
|
||||
timeoutMs: this.#replyTimeoutMs,
|
||||
onUpdate: stream ? async (update) => {
|
||||
const progress = update.type === 'text'
|
||||
? update.text
|
||||
: update.type === 'tool'
|
||||
? `正在使用${update.name}…`
|
||||
: update.text;
|
||||
if (progress) await stream.update(progress);
|
||||
} : undefined,
|
||||
});
|
||||
if (stream) {
|
||||
try {
|
||||
await stream.update(answer);
|
||||
await stream.complete();
|
||||
streamFinished = true;
|
||||
} catch (error) {
|
||||
stream.cancel?.();
|
||||
this.#logger.warn?.('[dsh-im:qq] QQ stream finalization failed; using a text reply:', error);
|
||||
}
|
||||
}
|
||||
if (!streamFinished) await this.#bot.sendText(target, answer);
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
} catch (error) {
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
|
||||
try {
|
||||
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
||||
await this.#state.markSeen(messageId);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.('[dsh-im:qq] failed to send the safe error reply:', sendError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
407
src/channels/qq/qq-controller.mjs
Normal file
407
src/channels/qq/qq-controller.mjs
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
|
||||
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
|
||||
const QR_TTL_MS = 5 * 60_000;
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function safeError(code, message) {
|
||||
return Object.freeze({ code, message });
|
||||
}
|
||||
|
||||
function publicAttempt(record) {
|
||||
if (!record) return null;
|
||||
return {
|
||||
attemptId: record.id,
|
||||
status: record.state,
|
||||
qrRevision: record.qrRevision,
|
||||
pollIntervalMs: 1_000,
|
||||
...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}),
|
||||
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
||||
...(record.botId ? { botId: record.botId } : {}),
|
||||
...(record.error ? { error: structuredClone(record.error) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class QqController {
|
||||
#qrAuth;
|
||||
#credentials;
|
||||
#configStore;
|
||||
#createRuntime;
|
||||
#deleteState;
|
||||
#logger;
|
||||
#runtimes = new Map();
|
||||
#errors = new Map();
|
||||
#attempts = new Map();
|
||||
#activeAttemptId = null;
|
||||
#transitions = new Map();
|
||||
#revision = 0;
|
||||
#closed = false;
|
||||
|
||||
constructor({
|
||||
qrAuth,
|
||||
credentials,
|
||||
configStore,
|
||||
createRuntime,
|
||||
deleteState = async () => {},
|
||||
logger = console,
|
||||
}) {
|
||||
if (!qrAuth || typeof qrAuth.start !== 'function') throw new TypeError('QQ QR auth is required');
|
||||
if (!credentials || typeof credentials.resolve !== 'function'
|
||||
|| typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
|
||||
throw new TypeError('QqController requires the DSH credential provider');
|
||||
}
|
||||
if (!configStore || typeof configStore.list !== 'function'
|
||||
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
|
||||
throw new TypeError('QqController requires a config store');
|
||||
}
|
||||
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
|
||||
this.#qrAuth = qrAuth;
|
||||
this.#credentials = credentials;
|
||||
this.#configStore = configStore;
|
||||
this.#createRuntime = createRuntime;
|
||||
this.#deleteState = deleteState;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.#closed) return this.status();
|
||||
for (const config of this.#configStore.list()) {
|
||||
await this.#withBotTransition(config.botId, async () => {
|
||||
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
|
||||
const appSecret = await this.#resolveSecret(config.secretRef);
|
||||
if (!appSecret) {
|
||||
this.#errors.set(config.botId, safeError('missing-secret', 'QQ 机器人凭据缺失,请移除后重新扫码。'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(config, appSecret);
|
||||
this.#errors.delete(config.botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(config.botId, safeError('connection-failed', 'QQ 连接未就绪,插件会自动重试。'));
|
||||
this.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error);
|
||||
} finally {
|
||||
this.#touch();
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async startProvisioning() {
|
||||
if (this.#closed) throw new Error('QQ controller is closed');
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
let firstQrResolve;
|
||||
let firstQrReject;
|
||||
const firstQr = new Promise((resolve, reject) => {
|
||||
firstQrResolve = resolve;
|
||||
firstQrReject = reject;
|
||||
});
|
||||
const record = {
|
||||
id: randomUUID(),
|
||||
state: 'starting',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
qrRevision: 0,
|
||||
verificationUrl: null,
|
||||
controller: new AbortController(),
|
||||
dispose: null,
|
||||
task: null,
|
||||
error: null,
|
||||
botId: null,
|
||||
};
|
||||
this.#attempts.set(record.id, record);
|
||||
this.#activeAttemptId = record.id;
|
||||
this.#touch();
|
||||
|
||||
try {
|
||||
record.dispose = this.#qrAuth.start({
|
||||
onQrDisplayed: (url) => {
|
||||
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
||||
const verificationUrl = cleanString(url);
|
||||
if (!verificationUrl) return;
|
||||
record.verificationUrl = verificationUrl;
|
||||
record.qrRevision += 1;
|
||||
record.expiresAt = Date.now() + QR_TTL_MS;
|
||||
record.state = 'pending';
|
||||
this.#touch();
|
||||
firstQrResolve();
|
||||
},
|
||||
onQrExpired: () => {
|
||||
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
||||
record.state = 'refreshing';
|
||||
record.verificationUrl = null;
|
||||
record.expiresAt = null;
|
||||
this.#touch();
|
||||
},
|
||||
onSuccess: (credentials) => {
|
||||
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
||||
record.task = this.#completeProvisioning(record, credentials);
|
||||
},
|
||||
onFailure: (error) => {
|
||||
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
||||
record.state = 'failed';
|
||||
record.error = safeError('qr-connect-failed', 'QQ 扫码服务暂时不可用,请重新生成二维码。');
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
this.#touch();
|
||||
firstQrReject(error);
|
||||
},
|
||||
}, { signal: record.controller.signal });
|
||||
await firstQr;
|
||||
return publicAttempt(record);
|
||||
} catch (error) {
|
||||
if (record.controller.signal.aborted) {
|
||||
record.state = 'cancelled';
|
||||
record.error = safeError('cancelled', '扫码绑定已取消。');
|
||||
} else if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
||||
record.state = 'failed';
|
||||
record.error = safeError('qr-start-failed', '无法生成 QQ 二维码,请稍后重试。');
|
||||
}
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
this.#touch();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
registrationStatus(attemptId) {
|
||||
return publicAttempt(this.#attempts.get(attemptId));
|
||||
}
|
||||
|
||||
async cancelProvisioning(attemptId) {
|
||||
const record = this.#attempts.get(attemptId);
|
||||
if (!record) return null;
|
||||
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
||||
record.controller.abort();
|
||||
record.dispose?.();
|
||||
await record.task?.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);
|
||||
}
|
||||
|
||||
async reconnectBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown QQ bot');
|
||||
await this.#withBotTransition(botId, async () => {
|
||||
const secret = await this.#resolveSecret(config.secretRef);
|
||||
if (!secret) throw new Error('QQ bot secret is missing');
|
||||
try {
|
||||
await this.#startRuntime(config, secret);
|
||||
this.#errors.delete(botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(botId, safeError('connection-failed', 'QQ 连接仍未就绪,请稍后重试。'));
|
||||
throw error;
|
||||
} finally {
|
||||
this.#touch();
|
||||
}
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown QQ bot');
|
||||
await this.#withBotTransition(botId, async () => {
|
||||
const previous = await this.#credentials.resolve(config.secretRef).catch(() => undefined);
|
||||
await this.#stopRuntime(botId);
|
||||
try {
|
||||
await this.#credentials.unset(config.secretRef);
|
||||
await this.#configStore.remove(botId);
|
||||
} catch (error) {
|
||||
if (previous?.value) {
|
||||
await this.#credentials.set(config.secretRef, previous.value).catch(() => undefined);
|
||||
await this.#startRuntime(config, previous.value).catch(() => undefined);
|
||||
}
|
||||
throw new Error('Unable to remove the QQ bot safely.', { cause: error });
|
||||
}
|
||||
await this.#deleteState({ botId, config }).catch((error) => {
|
||||
this.#logger.warn?.(`[dsh-im:qq] bot ${botId} state cleanup failed:`, error);
|
||||
});
|
||||
this.#errors.delete(botId);
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
status() {
|
||||
const bots = this.#configStore.list().map((config) => {
|
||||
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
||||
const connected = runtimeStatus?.ready === true
|
||||
&& runtimeStatus.qqConnectionState === 'connected'
|
||||
&& runtimeStatus.harnessReachable === true;
|
||||
const state = connected ? 'connected'
|
||||
: runtimeStatus?.qqConnectionState === 'connecting' ? 'connecting'
|
||||
: this.#errors.has(config.botId) || runtimeStatus?.qqConnectionState === 'failed'
|
||||
? 'error' : 'offline';
|
||||
return {
|
||||
botId: config.botId,
|
||||
state,
|
||||
connected,
|
||||
configured: true,
|
||||
bot: { name: 'QQ机器人', appIdMasked: maskQqAppId(config.appId) },
|
||||
health: {
|
||||
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
||||
summary: connected ? 'QQ WebSocket 长连接运行正常'
|
||||
: state === 'error' ? 'QQ 连接未就绪,插件会自动重试' : 'QQ 连接当前离线',
|
||||
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
||||
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
||||
},
|
||||
stats: {
|
||||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||||
},
|
||||
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
||||
};
|
||||
});
|
||||
const connectedCount = bots.filter((bot) => bot.connected).length;
|
||||
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
revision: this.#revision,
|
||||
state: active && ACTIVE_ATTEMPT_STATES.has(active.state) ? 'provisioning'
|
||||
: bots.length === 0 ? 'disconnected'
|
||||
: connectedCount === bots.length ? 'connected'
|
||||
: connectedCount > 0 ? 'degraded' : 'offline',
|
||||
bots,
|
||||
totals: { configured: bots.length, connected: connectedCount },
|
||||
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
||||
? { provisioning: publicAttempt(active) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
||||
}
|
||||
|
||||
async #completeProvisioning(record, credentials) {
|
||||
try {
|
||||
record.state = 'connecting';
|
||||
record.verificationUrl = null;
|
||||
record.expiresAt = null;
|
||||
this.#touch();
|
||||
const credential = Array.isArray(credentials) ? credentials[0] : null;
|
||||
const appId = cleanString(credential?.appId);
|
||||
const appSecret = cleanString(credential?.appSecret);
|
||||
const ownerUserOpenid = cleanString(credential?.userOpenid);
|
||||
if (!appId || !appSecret || !ownerUserOpenid) {
|
||||
throw new Error('QQ authorization returned incomplete credentials');
|
||||
}
|
||||
record.botId = await this.#activateBot(record, { appId, appSecret, ownerUserOpenid });
|
||||
record.state = 'connected';
|
||||
record.error = null;
|
||||
} catch (error) {
|
||||
if (record.controller.signal.aborted) {
|
||||
record.state = 'cancelled';
|
||||
record.error = safeError('cancelled', '扫码绑定已取消。');
|
||||
} else {
|
||||
record.state = 'failed';
|
||||
record.error = safeError('activation-failed', 'QQ 已授权,但无法安全保存接入配置。');
|
||||
this.#logger.error?.('[dsh-im:qq] provisioning failed:', error);
|
||||
}
|
||||
} finally {
|
||||
record.dispose?.();
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
this.#touch();
|
||||
}
|
||||
}
|
||||
|
||||
async #activateBot(record, { appId, appSecret, ownerUserOpenid }) {
|
||||
const identity = deriveQqBotIdentity(appId);
|
||||
const previousConfig = this.#configStore.getByAppId(appId);
|
||||
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
||||
const config = {
|
||||
botId: identity.botId,
|
||||
appId,
|
||||
secretRef: identity.secretRef,
|
||||
ownerUserOpenid,
|
||||
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
||||
connectedAt: new Date().toISOString(),
|
||||
};
|
||||
return this.#withBotTransition(identity.botId, async () => {
|
||||
await this.#credentials.set(identity.secretRef, appSecret);
|
||||
try {
|
||||
if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError');
|
||||
await this.#configStore.save(config);
|
||||
} catch (error) {
|
||||
await this.#restoreCredential(identity.secretRef, previousSecret);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError');
|
||||
await this.#startRuntime(config, appSecret);
|
||||
this.#errors.delete(identity.botId);
|
||||
} catch (error) {
|
||||
if (record.controller.signal.aborted) {
|
||||
await this.#stopRuntime(identity.botId);
|
||||
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
|
||||
else await this.#configStore.remove(identity.botId).catch(() => undefined);
|
||||
await this.#restoreCredential(identity.secretRef, previousSecret);
|
||||
throw error;
|
||||
}
|
||||
this.#errors.set(identity.botId, safeError('connection-failed', 'QQ 机器人已绑定,消息连接暂未就绪。'));
|
||||
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error);
|
||||
}
|
||||
this.#touch();
|
||||
return identity.botId;
|
||||
});
|
||||
}
|
||||
|
||||
async #startRuntime(config, appSecret) {
|
||||
await this.#stopRuntime(config.botId);
|
||||
const runtime = await this.#createRuntime({ botId: config.botId, config, appSecret });
|
||||
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
||||
throw new TypeError('createRuntime returned an invalid QQ runtime');
|
||||
}
|
||||
this.#runtimes.set(config.botId, runtime);
|
||||
try {
|
||||
await runtime.start();
|
||||
} catch (error) {
|
||||
await runtime.stop().catch(() => undefined);
|
||||
this.#runtimes.delete(config.botId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #stopRuntime(botId) {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
this.#runtimes.delete(botId);
|
||||
await runtime?.stop().catch((error) => {
|
||||
this.#logger.warn?.(`[dsh-im:qq] bot ${botId} failed to stop cleanly:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
async #resolveSecret(ref) {
|
||||
const result = await this.#credentials.resolve(ref).catch(() => undefined);
|
||||
return cleanString(result?.value);
|
||||
}
|
||||
|
||||
async #restoreCredential(ref, previous) {
|
||||
if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
|
||||
else await this.#credentials.unset(ref).catch(() => undefined);
|
||||
}
|
||||
|
||||
#withBotTransition(botId, operation) {
|
||||
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
||||
const current = previous.catch(() => undefined).then(operation);
|
||||
const settled = current.finally(() => {
|
||||
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
||||
});
|
||||
this.#transitions.set(botId, settled);
|
||||
return settled;
|
||||
}
|
||||
|
||||
#touch() {
|
||||
this.#revision += 1;
|
||||
}
|
||||
}
|
||||
207
src/channels/qq/qq-runtime.mjs
Normal file
207
src/channels/qq/qq-runtime.mjs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { QQBot, typingIndicator } from '@tencent-connect/qqbot-nodejs';
|
||||
|
||||
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
|
||||
|
||||
function timeoutError() {
|
||||
const error = new Error('QQ WebSocket did not become ready in time');
|
||||
error.code = 'connect-timeout';
|
||||
return error;
|
||||
}
|
||||
|
||||
export function createQqRuntimeStatus() {
|
||||
return {
|
||||
startedAt: null,
|
||||
ready: false,
|
||||
qqConnectionState: 'idle',
|
||||
harnessReachable: false,
|
||||
lastCheckedAt: null,
|
||||
lastConnectedAt: null,
|
||||
lastError: null,
|
||||
...createQqBridgeStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
export class QqRuntime {
|
||||
#config;
|
||||
#appSecret;
|
||||
#harness;
|
||||
#state;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
#createBot;
|
||||
#typingMiddleware;
|
||||
#status = createQqRuntimeStatus();
|
||||
#bot = null;
|
||||
#bridge = null;
|
||||
#abortController = null;
|
||||
#runTask = null;
|
||||
#starting = null;
|
||||
|
||||
constructor({
|
||||
config,
|
||||
appSecret,
|
||||
harness,
|
||||
state,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
connectTimeoutMs = 20_000,
|
||||
createBot = (options) => new QQBot(options),
|
||||
typingMiddleware = typingIndicator,
|
||||
}) {
|
||||
if (!config || !appSecret || !harness || !state) {
|
||||
throw new TypeError('QqRuntime requires config, app secret, Harness, and state');
|
||||
}
|
||||
this.#config = config;
|
||||
this.#appSecret = appSecret;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
this.#createBot = createBot;
|
||||
this.#typingMiddleware = typingMiddleware;
|
||||
}
|
||||
|
||||
get status() {
|
||||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#bot) 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.qqConnectionState = 'connecting';
|
||||
this.#status.lastError = null;
|
||||
await this.#harness.ensureRunning();
|
||||
this.#status.harnessReachable = true;
|
||||
|
||||
const sdkLogger = {
|
||||
error: (...args) => this.#logger.error?.(...args),
|
||||
warn: (...args) => this.#logger.warn?.(...args),
|
||||
info: (...args) => this.#logger.info?.(...args),
|
||||
debug: () => {},
|
||||
};
|
||||
const bot = this.#createBot({
|
||||
appId: this.#config.appId,
|
||||
appSecret: this.#appSecret,
|
||||
accountId: this.#config.botId,
|
||||
logger: sdkLogger,
|
||||
transport: 'websocket',
|
||||
tokenPrefetch: 'sync',
|
||||
});
|
||||
if (!bot || typeof bot.start !== 'function' || typeof bot.stop !== 'function') {
|
||||
throw new TypeError('QQ bot factory returned an invalid client');
|
||||
}
|
||||
this.#bot = bot;
|
||||
this.#bridge = new QqHarnessBridge({
|
||||
bot,
|
||||
ownerUserOpenid: this.#config.ownerUserOpenid,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
});
|
||||
bot.use?.(this.#typingMiddleware({
|
||||
keepAlive: true,
|
||||
predicate: (ctx) => ctx?.message?.senderId === this.#config.ownerUserOpenid,
|
||||
}));
|
||||
|
||||
const controller = new AbortController();
|
||||
this.#abortController = controller;
|
||||
let readyResolve;
|
||||
let readyReject;
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
readyResolve = resolve;
|
||||
readyReject = reject;
|
||||
});
|
||||
const onReady = () => {
|
||||
const now = Date.now();
|
||||
this.#status.ready = true;
|
||||
this.#status.qqConnectionState = 'connected';
|
||||
this.#status.lastCheckedAt = now;
|
||||
this.#status.lastConnectedAt = now;
|
||||
this.#status.lastError = null;
|
||||
readyResolve();
|
||||
};
|
||||
const onError = (error) => {
|
||||
if (!this.#status.ready) readyReject(error);
|
||||
else {
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} connection error:`, error);
|
||||
}
|
||||
};
|
||||
const onMessage = (_ctx, message) => this.#bridge?.accept(message);
|
||||
bot.on('ready', onReady);
|
||||
bot.on('resumed', onReady);
|
||||
bot.on('error', onError);
|
||||
bot.on('message', onMessage);
|
||||
|
||||
const runTask = Promise.resolve().then(() => bot.start(controller.signal));
|
||||
this.#runTask = runTask;
|
||||
runTask.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
readyReject(error);
|
||||
this.#status.ready = false;
|
||||
this.#status.qqConnectionState = 'failed';
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.(`[dsh-im:qq] bot ${this.#config.botId} connection stopped:`, error);
|
||||
});
|
||||
|
||||
let timer;
|
||||
try {
|
||||
await Promise.race([
|
||||
ready,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(timeoutError()), this.#connectTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
this.#status.ready = true;
|
||||
this.#status.qqConnectionState = 'connected';
|
||||
this.#status.lastCheckedAt = Date.now();
|
||||
this.#status.lastConnectedAt = Date.now();
|
||||
return this.status;
|
||||
} catch (error) {
|
||||
this.#status.ready = false;
|
||||
this.#status.qqConnectionState = 'failed';
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
await this.stop();
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
const bot = this.#bot;
|
||||
const bridge = this.#bridge;
|
||||
const runTask = this.#runTask;
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = null;
|
||||
this.#bot = null;
|
||||
this.#bridge = null;
|
||||
this.#runTask = null;
|
||||
try {
|
||||
bot?.stop();
|
||||
} catch (error) {
|
||||
this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} failed to stop cleanly:`, error);
|
||||
}
|
||||
await Promise.race([
|
||||
runTask?.catch(() => undefined) ?? Promise.resolve(),
|
||||
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
||||
]);
|
||||
await bridge?.waitForIdle();
|
||||
this.#status.ready = false;
|
||||
this.#status.qqConnectionState = 'idle';
|
||||
return this.status;
|
||||
}
|
||||
}
|
||||
24
src/channels/qq/qr-auth.mjs
Normal file
24
src/channels/qq/qr-auth.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { startQrConnect } from '@tencent-connect/qqbot-connector';
|
||||
|
||||
export class QqQrAuth {
|
||||
#start;
|
||||
#source;
|
||||
|
||||
constructor({ start = startQrConnect, source = 'deepseek-harness' } = {}) {
|
||||
if (typeof start !== 'function') throw new TypeError('QQ QR connector is required');
|
||||
this.#start = start;
|
||||
this.#source = source;
|
||||
}
|
||||
|
||||
start(callbacks, { signal } = {}) {
|
||||
if (!callbacks || typeof callbacks.onSuccess !== 'function'
|
||||
|| typeof callbacks.onFailure !== 'function') {
|
||||
throw new TypeError('QQ QR callbacks are required');
|
||||
}
|
||||
return this.#start(callbacks, {
|
||||
displayQrCodeToConsole: false,
|
||||
source: this.#source,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
96
src/channels/qq/state-store.mjs
Normal file
96
src/channels/qq/state-store.mjs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
|
||||
|
||||
function normalizeState(value) {
|
||||
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
|
||||
const sessions = {};
|
||||
if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) {
|
||||
for (const [key, sessionId] of Object.entries(value.sessions)) {
|
||||
if (typeof key === 'string' && typeof sessionId === 'string' && sessionId) {
|
||||
sessions[key] = sessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
sessions,
|
||||
seenMessageIds: Array.isArray(value.seenMessageIds)
|
||||
? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export class QqStateStore {
|
||||
#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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
81
test/channels/qq/bridge.test.mjs
Normal file
81
test/channels/qq/bridge.test.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
|
||||
function message(overrides = {}) {
|
||||
return {
|
||||
kind: 'c2c',
|
||||
rawEventType: 'C2C_MESSAGE_CREATE',
|
||||
senderId: 'owner-openid',
|
||||
senderIsBot: false,
|
||||
content: '请回答',
|
||||
messageId: 'msg-1',
|
||||
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'msg-1' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('QQ private messages stream Harness snapshots and finalize once', async () => {
|
||||
const frames = [];
|
||||
const sent = [];
|
||||
const seen = new Set();
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
openStream: () => ({
|
||||
update: async (text) => frames.push(text),
|
||||
complete: async () => frames.push('DONE'),
|
||||
cancel() {},
|
||||
}),
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-new',
|
||||
ensureRunning: async () => true,
|
||||
ask: async (_session, _text, { onUpdate }) => {
|
||||
await onUpdate({ type: 'text', text: '回答中' });
|
||||
return '最终回答';
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message());
|
||||
assert.deepEqual(frames, ['回答中', '最终回答', 'DONE']);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(seen.has('msg-1'), true);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
});
|
||||
|
||||
test('QQ bridge accepts only the scanner and requires an at-message event in groups', async () => {
|
||||
let asks = 0;
|
||||
const state = {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session',
|
||||
sessionExists: async () => true,
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
};
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async () => {} },
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: { sessionExists: async () => true, ask: async () => { asks += 1; return 'ok'; } },
|
||||
state,
|
||||
});
|
||||
await bridge.accept(message({ messageId: 'other', senderId: 'other-openid' }));
|
||||
await bridge.accept(message({
|
||||
messageId: 'group', kind: 'group', groupOpenid: 'group-1', rawEventType: 'GROUP_MESSAGE_CREATE',
|
||||
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'group' },
|
||||
}));
|
||||
assert.equal(asks, 0);
|
||||
assert.equal(bridge.status.messagesRejected, 1);
|
||||
});
|
||||
22
test/channels/qq/client-api.test.mjs
Normal file
22
test/channels/qq/client-api.test.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { normalizeProvisioning, normalizeSnapshot, safeQrSource } from '../../../plugin-src/client/channels/qq/api.js';
|
||||
|
||||
test('QQ client keeps only redacted bot and host-rendered QR state', () => {
|
||||
const qr = 'data:image/png;base64,YWJjZA==';
|
||||
assert.equal(safeQrSource(qr), qr);
|
||||
const provision = normalizeProvisioning({
|
||||
attemptId: 'attempt_1', status: 'pending', expiresAt: Date.now() + 1_000, qrCodeDataUrl: qr,
|
||||
});
|
||||
assert.equal(provision.qrCodeDataUrl, qr);
|
||||
const snapshot = normalizeSnapshot({
|
||||
bots: [{
|
||||
botId: 'qq_abc', connected: true, state: 'connected',
|
||||
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
|
||||
health: { summary: '运行正常' },
|
||||
}],
|
||||
});
|
||||
assert.equal(snapshot.totals.connected, 1);
|
||||
assert.equal(snapshot.bots[0].bot.appIdMasked, '123••••456');
|
||||
});
|
||||
40
test/channels/qq/client-ui.test.mjs
Normal file
40
test/channels/qq/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { AccountCard, QqSettingsTab } from '../../../plugin-src/client/channels/qq/index.js';
|
||||
|
||||
test('QQ settings uses the shared compact channel toolbar', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(QqSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
assert.match(markup, /class="ddt-page dqq-page dim-channelPage"/);
|
||||
assert.match(markup, /class="ddt-button dim-scanButton"/);
|
||||
assert.match(markup, />扫码绑定QQ机器人</);
|
||||
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
|
||||
});
|
||||
|
||||
test('QQ bot cards match the shared two-metric card treatment', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'qq_bot',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
|
||||
health: { summary: 'QQ WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /class="ddt-card dim-botCard"/);
|
||||
assert.match(markup, /data-im-channel-logo="qq"/);
|
||||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.match(markup, />消息通道<[^]*>最近检查</);
|
||||
assert.match(markup, />检查连接<[^]*>移除接入</);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复/);
|
||||
});
|
||||
53
test/channels/qq/config-and-qr.test.mjs
Normal file
53
test/channels/qq/config-and-qr.test.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { deriveQqBotIdentity, QqConfigStore } from '../../../src/channels/qq/config-store.mjs';
|
||||
import { QqQrAuth } from '../../../src/channels/qq/qr-auth.mjs';
|
||||
|
||||
test('QQ config stores only non-secret bot identity with mode 0600', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-qq-config-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const path = join(root, 'config.json');
|
||||
const identity = deriveQqBotIdentity('102345678');
|
||||
const store = await new QqConfigStore(path).load();
|
||||
await store.save({
|
||||
...identity,
|
||||
appId: '102345678',
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
createdAt: '2026-08-15T00:00:00.000Z',
|
||||
});
|
||||
const document = await readFile(path, 'utf8');
|
||||
assert.match(document, /102345678/);
|
||||
assert.match(document, /owner-openid/);
|
||||
assert.doesNotMatch(document, /appSecret|private-secret/);
|
||||
assert.deepEqual(store.get(identity.botId), {
|
||||
...identity,
|
||||
appId: '102345678',
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
createdAt: '2026-08-15T00:00:00.000Z',
|
||||
connectedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('QQ QR wrapper disables console output and identifies DeepSeek Harness', () => {
|
||||
let observed;
|
||||
const auth = new QqQrAuth({
|
||||
start(callbacks, options) {
|
||||
observed = { callbacks, options };
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
const callbacks = { onSuccess() {}, onFailure() {} };
|
||||
const signal = new AbortController().signal;
|
||||
const dispose = auth.start(callbacks, { signal });
|
||||
assert.equal(typeof dispose, 'function');
|
||||
assert.equal(observed.callbacks, callbacks);
|
||||
assert.deepEqual(observed.options, {
|
||||
displayQrCodeToConsole: false,
|
||||
source: 'deepseek-harness',
|
||||
signal,
|
||||
});
|
||||
});
|
||||
73
test/channels/qq/controller-and-rpc.test.mjs
Normal file
73
test/channels/qq/controller-and-rpc.test.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { QqController } from '../../../src/channels/qq/qq-controller.mjs';
|
||||
import { QQ_ENDPOINTS, createQqRpcHandler } from '../../../plugin-src/host/channels/qq/rpc.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('QQ QR success stores the secret off-config and starts a scanner-owned runtime', async () => {
|
||||
const qr = deferred();
|
||||
let callbacks;
|
||||
const values = new Map();
|
||||
const configs = [];
|
||||
let runtimeArgs;
|
||||
const controller = new QqController({
|
||||
qrAuth: {
|
||||
start(next) {
|
||||
callbacks = next;
|
||||
queueMicrotask(() => next.onQrDisplayed('https://q.qq.com/connect?task=opaque'));
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined,
|
||||
set: async (ref, value) => values.set(ref, value),
|
||||
unset: async (ref) => values.delete(ref),
|
||||
},
|
||||
configStore: {
|
||||
list: () => [...configs],
|
||||
get: (id) => configs.find((value) => value.botId === id) ?? null,
|
||||
getByAppId: (id) => configs.find((value) => value.appId === id) ?? null,
|
||||
save: async (value) => { configs.splice(0, configs.length, value); },
|
||||
remove: async () => null,
|
||||
},
|
||||
createRuntime: async (args) => {
|
||||
runtimeArgs = args;
|
||||
return { status: { ready: true, qqConnectionState: 'connected', harnessReachable: true }, start: async () => {}, stop: async () => {} };
|
||||
},
|
||||
});
|
||||
const started = await controller.startProvisioning();
|
||||
assert.equal(started.status, 'pending');
|
||||
callbacks.onSuccess([{ appId: 'app-id', appSecret: 'private-secret', userOpenid: 'scanner-openid' }]);
|
||||
while (controller.registrationStatus(started.attemptId).status !== 'connected') await new Promise((resolve) => setImmediate(resolve));
|
||||
const status = controller.status();
|
||||
assert.equal(status.bots[0].connected, true);
|
||||
assert.equal(configs[0].ownerUserOpenid, 'scanner-openid');
|
||||
assert.equal(values.get(configs[0].secretRef), 'private-secret');
|
||||
assert.equal(runtimeArgs.appSecret, 'private-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /private-secret|scanner-openid|secretRef/);
|
||||
qr.resolve();
|
||||
});
|
||||
|
||||
test('QQ RPC turns the host-only QR URL into an image and strips credential fields', async () => {
|
||||
const handler = createQqRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({
|
||||
attemptId: 'attempt_1', status: 'pending', verificationUrl: 'https://q.qq.com/opaque',
|
||||
appSecret: 'never-public', ownerUserOpenid: 'never-public', expiresAt: Date.now() + 1_000,
|
||||
}),
|
||||
registrationStatus: () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
reconnectBot: async () => ({}),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
const result = await handler(QQ_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
|
||||
assert.doesNotMatch(JSON.stringify(result), /q\.qq\.com|never-public|ownerUserOpenid|appSecret/);
|
||||
});
|
||||
72
test/channels/qq/runtime.test.mjs
Normal file
72
test/channels/qq/runtime.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
|
||||
import { QqRuntime } from '../../../src/channels/qq/qq-runtime.mjs';
|
||||
|
||||
class FakeBot extends EventEmitter {
|
||||
middlewares = [];
|
||||
stopped = false;
|
||||
use(value) { this.middlewares.push(value); }
|
||||
async start(signal) {
|
||||
queueMicrotask(() => this.emit('ready', {}));
|
||||
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }));
|
||||
}
|
||||
stop() { this.stopped = true; }
|
||||
async sendText() {}
|
||||
}
|
||||
|
||||
test('QQ runtime waits for gateway ready, installs typing, and stops its client', async () => {
|
||||
const bot = new FakeBot();
|
||||
let botOptions;
|
||||
const sdkLogs = [];
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' },
|
||||
appSecret: 'secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {},
|
||||
createBot: (options) => {
|
||||
botOptions = options;
|
||||
return bot;
|
||||
},
|
||||
logger: {
|
||||
debug: (...args) => sdkLogs.push(['debug', ...args]),
|
||||
info: (...args) => sdkLogs.push(['info', ...args]),
|
||||
},
|
||||
typingMiddleware: (options) => ({ name: 'typing-middleware', options }),
|
||||
connectTimeoutMs: 100,
|
||||
});
|
||||
const status = await runtime.start();
|
||||
assert.equal(status.ready, true);
|
||||
assert.equal(status.qqConnectionState, 'connected');
|
||||
assert.equal(bot.middlewares[0].name, 'typing-middleware');
|
||||
assert.equal(bot.middlewares[0].options.keepAlive, true);
|
||||
assert.equal(bot.middlewares[0].options.predicate({ message: { senderId: 'owner' } }), true);
|
||||
assert.equal(bot.middlewares[0].options.predicate({ message: { senderId: 'other' } }), false);
|
||||
botOptions.logger.debug('raw gateway payload');
|
||||
botOptions.logger.info('gateway ready');
|
||||
assert.deepEqual(sdkLogs, [['info', 'gateway ready']]);
|
||||
bot.emit('error', new Error('temporary disconnect'));
|
||||
bot.emit('resumed');
|
||||
assert.equal(runtime.status.ready, true);
|
||||
assert.equal(runtime.status.lastError, null);
|
||||
await runtime.stop();
|
||||
assert.equal(bot.stopped, true);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
|
||||
test('QQ runtime never reports ready when the gateway does not emit ready', async () => {
|
||||
const bot = new FakeBot();
|
||||
bot.start = async (signal) => new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }));
|
||||
const runtime = new QqRuntime({
|
||||
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' },
|
||||
appSecret: 'secret',
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {},
|
||||
createBot: () => bot,
|
||||
typingMiddleware: () => 'typing',
|
||||
connectTimeoutMs: 5,
|
||||
});
|
||||
await assert.rejects(() => runtime.start(), /did not become ready/);
|
||||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
|
|
@ -47,11 +47,12 @@ const DINGTALK_CLIENT_SOURCE_URL = new URL(
|
|||
import.meta.url,
|
||||
);
|
||||
|
||||
test('IM settings renders three compact logo channel tabs without enable switches', () => {
|
||||
test('IM settings renders four compact logo channel tabs without enable switches', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
weixinRpcCall: async () => ({ ok: true, value: {} }),
|
||||
dingtalkRpcCall: async () => ({ ok: true, value: {} }),
|
||||
qqRpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
assert.match(markup, /IM机器人/);
|
||||
|
|
@ -60,10 +61,12 @@ test('IM settings renders three compact logo channel tabs without enable switche
|
|||
assert.match(markup, />微信</);
|
||||
assert.match(markup, />飞书</);
|
||||
assert.match(markup, />钉钉</);
|
||||
assert.match(markup, />QQ</);
|
||||
assert.match(markup, /dim-logoWeixin/);
|
||||
assert.match(markup, /dim-logoFeishu/);
|
||||
assert.match(markup, /dim-logoDingtalk/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 3);
|
||||
assert.match(markup, /dim-logoQq/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 4);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
|
||||
|
|
|
|||
|
|
@ -3,18 +3,20 @@ import test from 'node:test';
|
|||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes Feishu, Weixin, and DingTalk inside one plugin context', async () => {
|
||||
test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context', async () => {
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||
applyWeixin: async (ctx, config) => calls.push(['weixin', ctx, config]),
|
||||
applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]),
|
||||
applyQq: async (ctx, config) => calls.push(['qq', ctx, config]),
|
||||
});
|
||||
const ctx = { marker: 'shared-context' };
|
||||
const config = {
|
||||
feishu: { domain: 'feishu' },
|
||||
weixin: { timeout: 30 },
|
||||
dingtalk: { replyTimeoutMs: 60_000 },
|
||||
qq: { replyTimeoutMs: 60_000 },
|
||||
};
|
||||
|
||||
await plugin.apply(ctx, config);
|
||||
|
|
@ -25,6 +27,7 @@ test('Host composes Feishu, Weixin, and DingTalk inside one plugin context', asy
|
|||
['feishu', ctx, config.feishu],
|
||||
['weixin', ctx, config.weixin],
|
||||
['dingtalk', ctx, config.dingtalk],
|
||||
['qq', ctx, config.qq],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -34,6 +37,7 @@ test('Host does not start Weixin when Feishu activation fails', async () => {
|
|||
applyFeishu: async () => { throw new Error('feishu unavailable'); },
|
||||
applyWeixin: async () => { weixinStarted = true; },
|
||||
applyDingtalk: async () => { throw new Error('DingTalk must not start'); },
|
||||
applyQq: async () => { throw new Error('QQ must not start'); },
|
||||
});
|
||||
|
||||
await assert.rejects(() => plugin.apply({}, {}), /feishu unavailable/);
|
||||
|
|
@ -46,8 +50,22 @@ test('Host does not start DingTalk when Weixin activation fails', async () => {
|
|||
applyFeishu: async () => {},
|
||||
applyWeixin: async () => { throw new Error('weixin unavailable'); },
|
||||
applyDingtalk: async () => { dingtalkStarted = true; },
|
||||
applyQq: async () => { throw new Error('QQ must not start'); },
|
||||
});
|
||||
|
||||
await assert.rejects(() => plugin.apply({}, {}), /weixin unavailable/);
|
||||
assert.equal(dingtalkStarted, false);
|
||||
});
|
||||
|
||||
test('Host does not start QQ when DingTalk activation fails', async () => {
|
||||
let qqStarted = false;
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async () => {},
|
||||
applyWeixin: async () => {},
|
||||
applyDingtalk: async () => { throw new Error('dingtalk unavailable'); },
|
||||
applyQq: async () => { qqStarted = true; },
|
||||
});
|
||||
|
||||
await assert.rejects(() => plugin.apply({}, {}), /dingtalk unavailable/);
|
||||
assert.equal(qqStarted, false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue