Add WeCom QR bot channel

This commit is contained in:
xmanrui 2026-08-15 20:53:35 +08:00
parent 601271a7f8
commit 4874d3e354
33 changed files with 5200 additions and 440 deletions

View file

@ -2,9 +2,9 @@
## 中文
通过扫码把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉和 QQ 机器人。
通过扫码把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信和 QQ 机器人。
> GitHub 简介:通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉和QQ)。
> GitHub 简介:通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。
## 界面
@ -15,6 +15,7 @@
- 飞书:扫码创建并绑定机器人,使用长连接收发消息;
- 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息;
- 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。
- 企业微信:使用企业微信 App 扫码创建并授权智能机器人,通过官方 WebSocket 长连接收消息,原生显示“正在思考中”、工具执行进度和流式回答。
- QQ:使用手机 QQ 扫码创建或绑定 QQ 机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。
其他 IM 平台可继续按同一渠道适配器结构接入。
@ -29,15 +30,17 @@ npx -y github:xmanrui/dsh-im install
钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。
企业微信接入使用企业微信官方扫码流程,不需要手动填写 Bot ID 或 Secret。请使用已加入企业且具有机器人创建或管理权限的企业微信账号扫码,并在手机端确认创建智能机器人。扫码创建的是企业微信智能机器人,不是让插件直接登录个人微信账号。插件不设置本机二次批准流程,企业微信中的机器人可见范围就是入站访问范围,请只开放给信任的企业成员和群聊。
QQ 接入使用腾讯 QQBot v2 官方扫码流程,不需要手动填写 AppID 或 AppSecret。默认腾讯授权页会把接入方显示为“第三方机器人”;扫码成功后创建或绑定的是 QQ 开放平台机器人,并不是让插件直接控制个人 QQ 账号。扫码者会自动成为该机器人在 Harness 中的使用者,不增加本机二次批准步骤。
## 设计
- Harness 中只注册一个「IM机器人」设置页;
- 飞书、微信、钉钉和 QQ 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
- 左侧使用渠道 Logo 切换微信、飞书、钉钉和 QQ,不使用启用/停用开关;
- 四个渠道保持独立的 RPC、凭据、连接监督和会话映射;
- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret`、QQ `app_secret` 或原始用户标识。
- 飞书、微信、钉钉、企业微信和 QQ 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信和 QQ,不使用启用/停用开关;
- 五个渠道保持独立的 RPC、凭据、连接监督和会话映射;
- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret` 或原始用户标识。
## 本地开发
@ -53,9 +56,9 @@ node bin/dsh-im.mjs install --source .
## English
Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, and QQ bots.
Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, and QQ bots.
> GitHub description: Connect IM bots to DeepSeek Harness by scanning a QR code (supports Feishu, WeChat, DingTalk, and QQ).
> GitHub description: Connect IM bots to DeepSeek Harness by scanning a QR code (supports Feishu, WeChat, DingTalk, WeCom, and QQ).
## Interface
@ -66,6 +69,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one se
- Feishu: create and bind a bot by scanning a QR code, then send and receive messages over a persistent connection.
- WeChat: bind a WeChat bot by scanning a QR code, then send and receive messages through Tencent iLink long polling.
- DingTalk: create and authorize a bot by scanning a QR code, receive messages through DingTalk Stream, and stream Harness replies through AI Cards.
- WeCom: create and authorize an intelligent bot by scanning with the WeCom app, receive messages over the official WebSocket connection, and natively show a thinking state, tool progress, and streaming replies.
- QQ: create or bind a QQ bot by scanning with mobile QQ, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned.
Other IM platforms can be added through the same channel-adapter structure.
@ -80,15 +84,17 @@ Restart `dsh web`, then open **Settings → Plugins → IM Bot**. The installer
For DingTalk, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members.
WeCom uses the official QR authorization flow and does not require manually entering a Bot ID or Secret. Scan with a WeCom account that belongs to an enterprise and can create or manage bots, then confirm creation of the intelligent bot in the mobile app. This creates a WeCom intelligent bot; it does not sign the plugin into a personal WeChat account. There is no second local sender-approval flow, so restrict the bot's WeCom visibility to trusted enterprise members and group chats.
QQ uses Tencent's official QQBot v2 QR flow and does not require manually entering an AppID or AppSecret. Tencent's default authorization page labels the integration as a third-party bot. Scanning creates or binds a QQ Open Platform bot; it does not give the plugin direct control of a personal QQ account. The scanner becomes the Harness user for that bot without an additional local approval step.
## Design
- Registers a single **IM Bot** settings page in Harness.
- Maintains the Feishu, WeChat, DingTalk, and QQ Host, client, and runtime sources in this repository without external standalone channel plugins.
- Uses channel logos for WeChat, Feishu, DingTalk, and QQ navigation without enable/disable switches.
- Maintains the Feishu, WeChat, DingTalk, WeCom, and QQ Host, client, and runtime sources in this repository without external standalone channel plugins.
- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, and QQ navigation without enable/disable switches.
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by channel.
- Sends only QR codes and redacted status data to the browser, never App Secrets, `bot_token`, DingTalk `client_secret`, QQ `app_secret`, or raw user identifiers.
- Sends only QR codes and redacted status data to the browser, never App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, or raw user identifiers.
## Local development

View file

@ -4,10 +4,12 @@ The Weixin iLink request format, QR-login states, and message fields are adapted
The DingTalk device-authorization request sequence and AI Card streaming protocol are adapted from DingTalk Real Team's [`dingtalk-openclaw-connector`](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector) project at commit `b2fd6e5ea2ff99bd213faac637d3da541b2bfaf4`, licensed under the MIT License and copyright 2026 DingTalk Real Team.
This package depends on [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队.
The WeCom QR-authorization request sequence is adapted from the official [`@wecom/wecom-openclaw-cli`](https://www.npmjs.com/package/@wecom/wecom-openclaw-cli) 1.1.0 package, whose npm metadata declares the ISC License. No CLI source or OpenClaw runtime is bundled in this package.
This package depends on [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) 1.0.7, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队.
QQ QR binding uses Tencent Connect's official [`@tencent-connect/qqbot-connector`](https://www.npmjs.com/package/@tencent-connect/qqbot-connector) 1.2.0 package as an external runtime dependency. Its npm metadata declares `UNLICENSED`; no connector source is copied into this project.
This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, Feishu, DingTalk, or QQ.
This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, WeCom, Feishu, DingTalk, or QQ.
The WeChat and QQ marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu and DingTalk marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners.
The WeChat and QQ marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu, DingTalk, and WeCom marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

18
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@larksuiteoapi/node-sdk": "1.73.0",
"@tencent-connect/qqbot-connector": "1.2.0",
"@tencent-connect/qqbot-nodejs": "1.0.4",
"@wecom/aibot-node-sdk": "1.0.7",
"dingtalk-stream": "2.1.4",
"qrcode": "1.5.4"
},
@ -587,6 +588,17 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@wecom/aibot-node-sdk": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@wecom/aibot-node-sdk/-/aibot-node-sdk-1.0.7.tgz",
"integrity": "sha512-51w+sTqunry6GD3HFvmuh0gArMSJDFE418vyvR1wMJHj1N6DaFuGD3HuaY2fazZs3mb9FWeQFX3+vU5t0Qhwmw==",
"license": "MIT",
"dependencies": {
"axios": "^1.6.7",
"eventemitter3": "^5.0.1",
"ws": "^8.16.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -879,6 +891,12 @@
"@esbuild/win32-x64": "0.25.9"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",

View file

@ -1,7 +1,7 @@
{
"name": "@xmanrui/dsh-im",
"version": "0.1.0",
"description": "通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉和QQ)。",
"description": "通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。",
"license": "MIT",
"type": "module",
"repository": {
@ -60,6 +60,7 @@
"@larksuiteoapi/node-sdk": "1.73.0",
"@tencent-connect/qqbot-connector": "1.2.0",
"@tencent-connect/qqbot-nodejs": "1.0.4",
"@wecom/aibot-node-sdk": "1.0.7",
"dingtalk-stream": "2.1.4",
"qrcode": "1.5.4"
},

View file

@ -57,3 +57,25 @@ export function QqLogoGlyph({ size } = {}) {
d: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673',
}));
}
export function WecomLogoGlyph({ size } = {}) {
return h('svg', {
...dimensions(size),
viewBox: '0 0 24 24',
focusable: 'false',
'aria-hidden': 'true',
'data-im-channel-logo': 'wecom',
},
h('path', {
fill: 'none',
stroke: '#3370FF',
strokeWidth: '2.35',
strokeLinecap: 'round',
strokeLinejoin: 'round',
d: 'M17.7 14.5c1.05-1.12 1.65-2.52 1.65-4.03 0-3.82-3.58-6.92-8-6.92s-8 3.1-8 6.92 3.58 6.92 8 6.92c1.17 0 2.28-.22 3.28-.62',
}),
h('path', { fill: '#07C160', d: 'M16.1 15.15c.7-.7 1.83-.7 2.53 0s.7 1.83 0 2.53-1.83.7-2.53 0-.7-1.83 0-2.53Z' }),
h('path', { fill: '#FFB800', d: 'M19.25 13.45a1.36 1.36 0 1 1 1.92 1.92 1.36 1.36 0 0 1-1.92-1.92Z' }),
h('path', { fill: '#FF7A00', d: 'M19.55 18.05a1.16 1.16 0 1 1 1.64 1.64 1.16 1.16 0 0 1-1.64-1.64Z' }),
h('path', { fill: '#3370FF', d: 'M15.25 18.75a.92.92 0 1 1 1.3 1.3.92.92 0 0 1-1.3-1.3Z' }));
}

View file

@ -0,0 +1,118 @@
export const WECOM_RPC_CHANNEL = '/wecom';
export const WECOM_ENDPOINTS = Object.freeze({
status: 'connection.status',
beginProvisioning: 'provision.begin',
pollProvisioning: 'provision.poll',
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
const QR_DATA_URL = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i;
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function text(value, fallback, max = 240) {
return typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : fallback;
}
function id(value) {
const result = text(value, '', 128);
return /^[a-z\d_-]+$/i.test(result) ? result : undefined;
}
function timestamp(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN;
return Number.isNaN(parsed) ? undefined : parsed;
}
export function unwrapRpcResult(result) {
if (!isRecord(result) || typeof result.ok !== 'boolean') throw new Error('企业微信服务返回了无法识别的响应');
if (!result.ok) {
const error = new Error(text(result.error?.message, '企业微信操作失败'));
error.code = text(result.error?.code, 'WECOM_RPC_ERROR', 80);
throw error;
}
return result.value;
}
export function safeQrSource(value) {
return typeof value === 'string' && value.length <= 2 * 1024 * 1024 && QR_DATA_URL.test(value)
? value : undefined;
}
export function normalizeProvisioning(value, now = Date.now()) {
const source = isRecord(value?.provisioning) ? value.provisioning : value;
if (!isRecord(source)) throw new Error('企业微信服务没有返回扫码绑定进度');
const attemptId = id(source.attemptId);
if (!attemptId) throw new Error('企业微信扫码服务没有返回有效的绑定任务');
const reported = text(source.status, 'failed', 32);
const result = {
attemptId,
status: PROVISION_STATES.has(reported) ? reported : 'failed',
expiresAt: timestamp(source.expiresAt) ?? now + 5 * 60_000,
pollIntervalMs: Math.min(10_000, Math.max(500, Number(source.pollIntervalMs) || 1_000)),
qrRevision: Number.isSafeInteger(source.qrRevision) ? source.qrRevision : 0,
};
const qrCodeDataUrl = safeQrSource(source.qrCodeDataUrl);
if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl;
if (id(source.botId)) result.botId = id(source.botId);
if (isRecord(source.error)) result.error = {
code: text(source.error.code, 'WECOM_PROVISION_FAILED', 80),
message: text(source.error.message, '企业微信机器人没有接入完成'),
};
return result;
}
function normalizeBot(value) {
if (!isRecord(value) || !id(value.botId)) return undefined;
const connected = value.connected === true;
const state = ACCOUNT_STATES.has(value.state) ? value.state : 'offline';
return {
botId: id(value.botId),
connected,
state: connected ? 'connected' : state,
bot: {
name: text(value.bot?.name, '企业微信机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),
},
health: {
summary: text(value.health?.summary, connected ? '企业微信 WebSocket 长连接运行正常' : '企业微信连接尚未就绪'),
lastCheckedAt: timestamp(value.health?.lastCheckedAt),
},
error: isRecord(value.error) ? {
code: text(value.error.code, 'WECOM_ACCOUNT_ERROR', 80),
message: text(value.error.message, '企业微信连接尚未就绪'),
} : null,
};
}
export function normalizeSnapshot(value) {
const source = isRecord(value?.snapshot) ? value.snapshot : value;
if (!isRecord(source) || !Array.isArray(source.bots)) throw new Error('企业微信服务没有返回有效的机器人列表');
const bots = source.bots.map(normalizeBot).filter(Boolean);
return {
revision: Number.isSafeInteger(source.revision) ? source.revision : 0,
bots,
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
};
}
export function presentError(error) {
return {
code: text(error?.code, 'WECOM_ERROR', 80),
message: text(error?.message, '企业微信操作失败,请稍后重试'),
};
}
export function formatRemaining(milliseconds) {
const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1_000) || 0);
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
}

View file

@ -0,0 +1,344 @@
import * as React from 'react';
import { WecomLogoGlyph } from '../../channel-logos.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
WECOM_ENDPOINTS,
WECOM_RPC_CHANNEL,
formatRemaining,
normalizeProvisioning,
normalizeSnapshot,
presentError,
safeQrSource,
unwrapRpcResult,
} from './api.js';
import { installWecomStyles } from './styles.js';
const h = React.createElement;
const ACTIVE_STATES = new Set(['pending', 'refreshing', 'connecting']);
const Button = React.forwardRef(function Button({ children, kind = 'secondary', className = '', ...props }, ref) {
return h('button', {
...props,
ref,
type: 'button',
className: `ddt-button ${className}`.trim(),
'data-kind': kind,
}, children);
});
function checkedTime(value) {
if (!value) return '尚未检查';
try {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit', minute: '2-digit', second: '2-digit',
}).format(new Date(value));
} catch {
return '刚刚';
}
}
function Heading({ totals, adding, busy, onAdd, addButtonRef }) {
return h('div', { className: 'ddt-heading' },
h('div', { className: 'ddt-tools' },
h(Button, {
kind: 'primary',
className: 'dim-scanButton',
onClick: onAdd,
disabled: adding || busy,
ref: addButtonRef,
}, adding ? '正在绑定' : '扫码绑定企业微信机器人'),
totals.configured > 0
? h('div', { className: 'ddt-badge dim-onlineBadge' },
h('span', null, `${totals.connected} / ${totals.configured} 在线`))
: null));
}
function LoadingView() {
return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView', 'aria-busy': 'true' },
h('div', { className: 'ddt-spinner dim-spinner' }),
h('span', null, '正在读取企业微信机器人状态…'));
}
function EmptyView({ busy, onStart }) {
return h('div', { className: 'ddt-card dim-surfaceCard' },
h('div', { className: 'ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView' },
h('div', { className: 'dim-emptyCopy' },
h('div', { className: 'ddt-stateLabel dim-stateLabel' },
h('span', { className: 'ddt-dot dim-stateDot' }), h('span', null, '尚未绑定企业微信机器人')),
h('h3', null, '使用企业微信 App 扫码创建智能机器人'),
h('p', null, '扫码由腾讯官方页面完成,不需要手动填写 Bot ID 或 Secret。创建成功后,机器人会自动连接 DeepSeek Harness。'),
h('div', { className: 'ddt-actions dim-viewActions' },
h(Button, { kind: 'primary', onClick: onStart, disabled: busy },
busy ? '正在生成二维码…' : '生成企业微信二维码'))),
h('div', { className: 'ddt-brandMark dim-emptyBrand dwecom-brand', 'aria-hidden': 'true' },
h(WecomLogoGlyph, { size: 64 }))));
}
function QrPanel({ provision, now, busy, onRefresh, onCancel }) {
const source = safeQrSource(provision.qrCodeDataUrl);
const remaining = Math.max(0, provision.expiresAt - now);
const duration = Math.max(1, provision.durationMs ?? 5 * 60_000);
const progress = Math.round(Math.min(1, remaining / duration) * 100);
const refreshing = provision.status === 'refreshing';
return h('div', { className: 'ddt-card dim-surfaceCard' },
h('div', { className: 'ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout' },
h('div', { className: 'ddt-qrColumn dim-qrColumn' },
h('div', { className: 'ddt-qrFrame dim-qrFrame' },
source ? h('img', { src: source, alt: '用于绑定企业微信机器人的一次性二维码' })
: h('div', { className: 'ddt-qrFallback dim-qrFallback' },
refreshing ? '二维码正在自动刷新…' : '二维码图片正在生成…')),
h('div', { className: 'ddt-countdown dim-countdown' },
h('div', { className: 'ddt-countdownTop dim-countdownTop' },
h('span', null, '当前二维码有效时间'),
h('strong', null, refreshing ? '--:--' : formatRemaining(remaining))),
h('div', { className: 'ddt-progress dim-progress', style: { '--ddt-progress': `${progress}%` } }, h('span')))),
h('div', { className: 'ddt-qrCopy dim-qrCopy' },
h('div', { className: 'ddt-stateLabel dim-stateLabel' },
h('span', { className: 'ddt-dot dim-stateDot', 'data-tone': 'warning' }),
h('span', null, refreshing ? '正在刷新二维码' : '等待企业微信 App 扫码')),
h('h3', null, '使用企业微信 App 完成智能机器人授权'),
h('p', null, '企业微信官方页面会创建一个智能机器人,并把连接凭据安全交给本机 Harness Host。'),
h('ol', { className: 'ddt-steps dim-steps' },
h('li', null, '打开企业微信 App,扫描左侧二维码'),
h('li', null, '在腾讯授权页面确认创建智能机器人'),
h('li', null, '返回这里等待连接完成')),
h('div', { className: 'ddt-actions dim-viewActions' },
h(Button, { onClick: onRefresh, disabled: busy }, '重新生成二维码'),
h(Button, { kind: 'quiet', onClick: onCancel, disabled: busy }, '取消')))));
}
function ProvisionView({ provision, busy, onRetry, onClose }) {
if (provision.status === 'connecting') {
return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-specialView', 'aria-busy': 'true' },
h('div', { className: 'ddt-spinner dim-spinner' }),
h('h3', null, '企业微信已授权,正在连接机器人'),
h('p', null, '凭据正在写入本机,并启动企业微信 WebSocket 消息连接。'));
}
const error = provision.error ?? { code: 'WECOM_PROVISION_FAILED', message: '企业微信机器人没有绑定完成' };
return h('div', { className: 'ddt-card dim-surfaceCard' },
h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' },
h('h3', null, '企业微信机器人没有绑定完成'),
h('p', null, error.message),
h('span', { className: 'ddt-errorCode' }, error.code),
h('div', { className: 'ddt-actions dim-viewActions' },
h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
}
function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
return h('div', { className: 'ddt-confirm dim-confirm', role: 'alertdialog' },
h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`),
h('p', null, '这会停止消息连接,并删除本机保存的应用凭据、机器人配置及会话映射。企业微信平台中的机器人不会被自动删除。'),
h('div', { className: 'ddt-actions dim-viewActions' },
h(Button, { onClick: onCancel, disabled: busy }, '保留机器人'),
h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入')));
}
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId },
h('div', { className: 'ddt-cardBody dim-botCardBody' },
h('div', { className: 'ddt-accountTop dim-botCardTop' },
h('div', { className: 'ddt-accountIdentity dim-botIdentity' },
h('div', { className: 'ddt-avatar dim-botAvatar dwecom-avatar', 'aria-hidden': 'true' }, h(WecomLogoGlyph, { size: 29 })),
h('div', { className: 'dim-botName' },
h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))),
h('div', { className: 'ddt-health dim-botHealth' },
h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), h('span', null, stateLabel))),
h('dl', { className: 'ddt-metrics dim-botMetrics' },
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
h('div', { className: 'ddt-summary dim-cardSummary' }, account.error?.message ?? account.health.summary),
h('div', { className: 'ddt-actions dim-cardActions' },
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
removing ? h(RemoveConfirmation, {
account, busy: busy === 'delete', onConfirm: onConfirmRemove, onCancel: onCancelRemove,
}) : null);
}
export function WecomSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({ phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null });
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
const [removeTarget, setRemoveTarget] = React.useState(null);
const [now, setNow] = React.useState(Date.now());
const mounted = React.useRef(true);
const addButtonRef = React.useRef(null);
React.useEffect(() => {
const disposeDingtalk = installDingtalkStyles();
const disposeWecom = installWecomStyles();
mounted.current = true;
return () => {
mounted.current = false;
disposeWecom();
disposeDingtalk();
};
}, []);
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== 'function') throw new TypeError('企业微信设置页缺少 RPC 连接');
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(WECOM_ENDPOINTS.status, {}, signal));
if (!mounted.current || signal?.aborted) return undefined;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning,
durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
});
return snapshot;
} catch (error) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
setModel((current) => ({ ...current, phase: silent ? current.phase : 'error', error: presentError(error) }));
}
return undefined;
}
}, [invoke]);
React.useEffect(() => {
const controller = new AbortController();
void loadStatus({ signal: controller.signal, restore: true });
return () => controller.abort();
}, [loadStatus]);
React.useEffect(() => {
if (model.phase !== 'ready') return undefined;
const controller = new AbortController();
const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15_000);
return () => { controller.abort(); window.clearInterval(timer); };
}, [loadStatus, model.phase]);
React.useEffect(() => {
if (!provision || !ACTIVE_STATES.has(provision.status)) return undefined;
const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [provision?.attemptId, provision?.status]);
const startProvisioning = React.useCallback(async (replace = false) => {
setBusy(true);
try {
if (replace && provision?.attemptId) await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
if (!mounted.current) return;
setProvision({ status: 'starting' });
const started = normalizeProvisioning(await invoke(WECOM_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' }));
if (!mounted.current) return;
setNow(Date.now());
setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) });
} catch (error) {
if (mounted.current) setProvision({ status: 'failed', error: presentError(error) });
} finally {
if (mounted.current) setBusy(false);
}
}, [invoke, provision?.attemptId]);
const closeProvision = React.useCallback(async () => {
setBusy(true);
try {
if (provision?.attemptId && ACTIVE_STATES.has(provision.status)) {
await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
}
if (mounted.current) setProvision(null);
} finally {
if (mounted.current) setBusy(false);
}
}, [invoke, provision?.attemptId, provision?.status]);
React.useEffect(() => {
const attemptId = provision?.attemptId;
if (!attemptId || !ACTIVE_STATES.has(provision.status)) return undefined;
const controller = new AbortController();
let disposed = false;
let timer;
const poll = async () => {
try {
const current = normalizeProvisioning(await invoke(WECOM_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal));
if (disposed || controller.signal.aborted || !mounted.current) return;
if (current.status === 'connected') {
setProvision(null);
await loadStatus({ signal: controller.signal, silent: true });
return;
}
setProvision((previous) => previous?.attemptId === attemptId
? { ...previous, ...current, durationMs: current.qrRevision !== previous.qrRevision
? Math.max(1, current.expiresAt - Date.now()) : previous.durationMs }
: previous);
if (ACTIVE_STATES.has(current.status)) timer = window.setTimeout(poll, current.pollIntervalMs);
} catch (error) {
if (!disposed && !controller.signal.aborted && mounted.current) {
setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
}
}
};
timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000);
return () => { disposed = true; controller.abort(); window.clearTimeout(timer); };
}, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]);
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current) setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
return snapshot;
} finally {
if (mounted.current) setBusyByBot((current) => {
const next = { ...current }; delete next[account.botId]; return next;
});
}
}, [invoke]);
let provisionView = null;
if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请企业微信二维码…');
else if (['pending', 'refreshing'].includes(provision?.status)) provisionView = h(QrPanel, {
provision, now, busy, onRefresh: () => void startProvisioning(true), onCancel: () => void closeProvision(),
});
else if (provision) provisionView = h(ProvisionView, {
provision, busy, onRetry: () => void startProvisioning(true), onClose: () => void closeProvision(),
});
const botList = model.bots.length > 0
? h('section', { className: 'dim-listSection' },
h('div', { className: 'ddt-listHeading dim-listHeading' },
h('h3', null, '已绑定的企业微信机器人'), h('span', null, `${model.bots.length} 个`)),
h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
h('li', { key: account.botId }, h(AccountCard, {
account,
busy: busyByBot[account.botId],
removing: removeTarget === account.botId,
onReconnect: () => void botAction(account, 'reconnect', WECOM_ENDPOINTS.reconnectBot, { botId: account.botId }),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {
await botAction(account, 'delete', WECOM_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true });
if (mounted.current) setRemoveTarget(null);
},
})))))
: null;
return h('section', { className: 'ddt-page dwecom-page dim-channelPage', 'aria-label': '企业微信设置' },
h(Heading, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }),
model.phase === 'loading' ? h(LoadingView)
: model.phase === 'error'
? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取企业微信机器人状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取')))
: h(React.Fragment, null,
provisionView,
model.bots.length === 0 && !provision ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
botList));
}
export function apply(ctx) {
ctx.effect(() => installWecomStyles(), 'wecom-settings: install client styles');
const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal);
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab', id: 'wecom', order: 45, label: '企业微信', inject: () => ({ rpcCall }),
}, WecomSettingsTab));
}

View file

@ -0,0 +1,19 @@
export const WECOM_STYLE_ID = 'xmanrui-dsh-im-wecom-settings';
const CSS = String.raw`
.dwecom-page { --ddt-accent: #3370ff; --ddt-accent-deep: #245bdb; --ddt-accent-wash: #eef4ff; }
.dwecom-avatar, .dwecom-brand { color: #3370ff; background: #fff; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); }
.dwecom-avatar svg, .dwecom-brand svg { display: block; }
`;
export function installWecomStyles() {
if (typeof document === 'undefined') return () => {};
const existing = document.querySelector(`style[data-plugin-css="${WECOM_STYLE_ID}"]`);
if (existing) return () => {};
const style = document.createElement('style');
style.dataset.plugin = '@xmanrui/dsh-im';
style.dataset.pluginCss = WECOM_STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
return () => style.remove();
}

View file

@ -1,6 +1,12 @@
import * as React from 'react';
import { DingtalkLogoGlyph, FeishuLogoGlyph, QqLogoGlyph, WeixinLogoGlyph } from './channel-logos.js';
import {
DingtalkLogoGlyph,
FeishuLogoGlyph,
QqLogoGlyph,
WecomLogoGlyph,
WeixinLogoGlyph,
} from './channel-logos.js';
import { DINGTALK_RPC_CHANNEL } from './channels/dingtalk/api.js';
import { DingtalkSettingsTab } from './channels/dingtalk/index.js';
import { FeishuSettingsTab } from './channels/feishu/index.js';
@ -9,6 +15,9 @@ import { installFeishuStyles } from './channels/feishu/styles.js';
import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
import { QqSettingsTab } from './channels/qq/index.js';
import { installQqStyles } from './channels/qq/styles.js';
import { WECOM_RPC_CHANNEL } from './channels/wecom/api.js';
import { WecomSettingsTab } from './channels/wecom/index.js';
import { installWecomStyles } from './channels/wecom/styles.js';
import { WeixinSettingsTab } from './channels/weixin/index.js';
import { WEIXIN_RPC_CHANNEL } from './channels/weixin/api.js';
import { installWeixinStyles } from './channels/weixin/styles.js';
@ -23,6 +32,7 @@ const CHANNELS = Object.freeze([
{ id: 'weixin', label: '微信' },
{ id: 'feishu', label: '飞书' },
{ id: 'dingtalk', label: '钉钉' },
{ id: 'wecom', label: '企业微信' },
{ id: 'qq', label: 'QQ' },
]);
@ -45,14 +55,19 @@ function QqLogo() {
return h('span', { className: 'dim-logo dim-logoQq', 'aria-hidden': 'true' }, h(QqLogoGlyph));
}
function WecomLogo() {
return h('span', { className: 'dim-logo dim-logoWecom', 'aria-hidden': 'true' }, h(WecomLogoGlyph));
}
function ChannelLogo({ channel }) {
if (channel === 'weixin') return h(WeixinLogo);
if (channel === 'feishu') return h(FeishuLogo);
if (channel === 'dingtalk') return h(DingtalkLogo);
if (channel === 'wecom') return h(WecomLogo);
return h(QqLogo);
}
export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }) {
export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }) {
const [selected, setSelected] = React.useState('weixin');
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
return h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
@ -87,14 +102,22 @@ export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixi
? h(FeishuSettingsTab, { rpcCall: feishuRpcCall })
: active.id === 'dingtalk'
? h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall })
: h(QqSettingsTab, { rpcCall: qqRpcCall })),
: active.id === 'wecom'
? h(WecomSettingsTab, { rpcCall: wecomRpcCall })
: h(QqSettingsTab, { rpcCall: qqRpcCall })),
),
);
}
export function apply(ctx) {
ctx.effect(() => {
const disposers = [installFeishuStyles(), installWeixinStyles(), installQqStyles(), installImStyles()];
const disposers = [
installFeishuStyles(),
installWeixinStyles(),
installWecomStyles(),
installQqStyles(),
installImStyles(),
];
return () => {
for (const dispose of disposers.reverse()) dispose();
};
@ -108,12 +131,14 @@ export function apply(ctx) {
ctx.connection.rpc.call(DINGTALK_RPC_CHANNEL, endpoint, payload, signal);
const qqRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal);
const wecomRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal);
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
id: 'im',
order: 20,
label: 'IM机器人',
inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }),
inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }),
}, IMSettingsTab));
}

View file

@ -31,11 +31,13 @@ const CSS = String.raw`
.dim-logoDingtalk svg { width: 24px; height: 24px; }
.dim-logoQq { color: white; background: #1677ff; }
.dim-logoQq svg { width: 21px; height: 21px; }
.dim-logoWecom { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); }
.dim-logoWecom svg { width: 22px; height: 22px; }
.dim-channelCopy { min-width: 0; display: block; }
.dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-line-divider, #eef0f3); }
.dim-panel { min-width: 0; container-type: inline-size; }
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page { width: 100%; max-width: none; padding: 0 0 24px; }
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page { width: 100%; max-width: none; padding: 0 0 24px; }
.dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; }
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; justify-content: space-between; }
.dim-panel .bxf-headingTools .dim-scanButton, .dim-panel .dxw-tools .dim-scanButton, .dim-panel .ddt-tools .dim-scanButton { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; box-shadow: none; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; }

View 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:wecom] ${connected}/${configured} bots connected; retrying in ${delay}ms`);
this.#schedule(delay);
return;
}
this.#retryIndex = 0;
this.#schedule(this.#healthyIntervalMs);
} catch (error) {
if (this.#closed) return;
const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
this.#retryIndex += 1;
this.#logger.warn?.(`[dsh-im:wecom] connection reconciliation failed; retrying in ${delay}ms`, error);
this.#schedule(delay);
}
}
}
export function createConnectionSupervisor(options) {
return new ConnectionSupervisor(options);
}

View file

@ -0,0 +1,29 @@
import { createProductionController } from './production.mjs';
import { installWecomRpc } from './rpc.mjs';
export const name = 'dsh-im-wecom-host';
export const inject = ['connection', 'credentials', 'webServer'];
export async function apply(ctx, config = {}) {
if (config?.controller) return installWecomRpc(ctx, config.controller, config.rpcOptions);
const production = await createProductionController(ctx, config, config.internals);
const disposeRpc = installWecomRpc(ctx, production.controller, config.rpcOptions);
ctx.effect(() => async () => production.close(), 'dsh-im: close Enterprise WeChat bot connections');
return disposeRpc;
}
export function createWecomHostPlugin(config) {
return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) });
}
export { createConnectionSupervisor, ConnectionSupervisor } from './connection-supervisor.mjs';
export { createProductionController } from './production.mjs';
export {
WECOM_ENDPOINTS,
WECOM_RPC_CHANNEL,
WECOM_RPC_ENDPOINTS,
createWecomRpcHandler,
installWecomRpc,
} from './rpc.mjs';
export { WecomController } from '../../../../src/channels/wecom/wecom-controller.mjs';
export { WecomRuntime } from '../../../../src/channels/wecom/wecom-runtime.mjs';

View file

@ -0,0 +1,113 @@
import { unlink } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { WecomConfigStore } from '../../../../src/channels/wecom/config-store.mjs';
import { WecomHarnessClient } from '../../../../src/channels/wecom/harness-client.mjs';
import { WecomQrAuth } from '../../../../src/channels/wecom/qr-auth.mjs';
import { WecomStateStore } from '../../../../src/channels/wecom/state-store.mjs';
import { WecomController } from '../../../../src/channels/wecom/wecom-controller.mjs';
import { WecomRuntime } from '../../../../src/channels/wecom/wecom-runtime.mjs';
import { createConnectionSupervisor } from './connection-supervisor.mjs';
function harnessOrigin(webServer, configured) {
if (configured !== undefined) return new URL(configured);
const port = webServer?.port;
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error('dsh-im Enterprise WeChat requires an initialized DSH webServer port');
}
return new URL(`http://127.0.0.1:${port}`);
}
function pluginPaths(config) {
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-wecom'));
return {
config: resolve(config.configPath ?? join(root, 'config.json')),
bots: resolve(config.botsDir ?? join(root, 'bots')),
};
}
export async function createProductionController(ctx, config = {}, internals = {}) {
if (!ctx?.credentials) throw new TypeError('dsh-im Enterprise WeChat requires ctx.credentials');
if (!ctx?.webServer) throw new TypeError('dsh-im Enterprise WeChat requires ctx.webServer');
const ConfigStore = internals.ConfigStore ?? WecomConfigStore;
const StateStore = internals.StateStore ?? WecomStateStore;
const Harness = internals.HarnessClient ?? WecomHarnessClient;
const Controller = internals.Controller ?? WecomController;
const Runtime = internals.Runtime ?? WecomRuntime;
const QrAuth = internals.QrAuth ?? WecomQrAuth;
const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor;
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:wecom') : (ctx.logger ?? console);
const paths = pluginPaths(config);
const configStore = await new ConfigStore(paths.config).load();
const qrAuth = internals.qrAuth ?? new QrAuth({
source: config.qrSource ?? 'deepseek-harness',
platform: config.qrPlatform,
});
const stateStores = new Map();
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
const stateFor = async (botId) => {
let state = stateStores.get(botId);
if (!state) {
state = await new StateStore(statePath(botId)).load();
stateStores.set(botId, state);
}
return state;
};
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: resolve(config.workspace ?? process.cwd()),
agentPreset: config.agentPreset ?? 'standard',
autostart: false,
dshBin: config.dshBin ?? 'dsh',
});
const controller = new Controller({
qrAuth,
credentials: ctx.credentials,
configStore,
logger,
createRuntime: async ({ botId, config: botConfig, secret }) => new Runtime({
config: botConfig,
secret,
harness,
state: await stateFor(botId),
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
info: (...args) => logger.info?.(`[${botId}]`, ...args),
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
},
}),
deleteState: async ({ botId }) => {
const state = stateStores.get(botId);
stateStores.delete(botId);
if (state && typeof state.remove === 'function') return state.remove();
try {
await unlink(statePath(botId));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
},
});
const supervisor = createSupervisor({
controller,
harness,
logger,
retryDelaysMs: config.retryDelaysMs,
healthyIntervalMs: config.healthyIntervalMs,
}).start();
return {
controller,
ready: supervisor.ready,
async close() {
await supervisor.close();
await controller.close();
harness.stopManagedProcess();
},
};
}

View file

@ -0,0 +1,137 @@
import QRCode from 'qrcode';
export const WECOM_RPC_CHANNEL = '/wecom';
export const WECOM_ENDPOINTS = Object.freeze({
status: 'connection.status',
beginProvisioning: 'provision.begin',
pollProvisioning: 'provision.poll',
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
});
export const WECOM_RPC_ENDPOINTS = Object.freeze(Object.values(WECOM_ENDPOINTS));
const FORBIDDEN_PUBLIC_KEYS = new Set([
'secret', 'secretRef', 'scode', 'remoteBotId', 'verificationUrl', 'bot_info', 'botid',
]);
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function exactKeys(value, allowed) {
return isRecord(value) && Object.keys(value).every((key) => allowed.includes(key));
}
function validId(value) {
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
}
function payloadFailure(endpoint, payload) {
if (!isRecord(payload)) return 'Payload must be an object.';
if (endpoint === WECOM_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
if (endpoint === WECOM_ENDPOINTS.beginProvisioning) {
return exactKeys(payload, ['locale']) && (payload.locale === undefined || payload.locale === 'zh-CN')
? null : 'provision.begin received unsupported fields.';
}
if ([WECOM_ENDPOINTS.pollProvisioning, WECOM_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
return exactKeys(payload, ['attemptId']) && validId(payload.attemptId)
? null : `${endpoint} requires an attemptId.`;
}
if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
}
if (endpoint === WECOM_ENDPOINTS.deleteBot) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
? null : 'bot.delete requires a botId and confirm=true.';
}
return 'Unknown Enterprise WeChat endpoint.';
}
function sanitizePublic(value) {
if (Array.isArray(value)) return value.map(sanitizePublic);
if (!isRecord(value)) return value;
const safe = {};
for (const [key, child] of Object.entries(value)) {
if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child);
}
return safe;
}
async function qrDataUrl(value) {
return QRCode.toDataURL(value, {
type: 'image/png', errorCorrectionLevel: 'M', margin: 2, width: 320,
});
}
async function withEncodedQr(value, encodeQr) {
if (!value || typeof value.verificationUrl !== 'string') return sanitizePublic(value);
return sanitizePublic({ ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) });
}
async function publicStatus(status, encodeQr) {
const value = structuredClone(status);
if (value?.provisioning) value.provisioning = await withEncodedQr(value.provisioning, encodeQr);
return sanitizePublic(value);
}
export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
if (typeof controller?.[method] !== 'function') {
throw new TypeError(`A complete Enterprise WeChat controller is required (${method})`);
}
}
const qrCache = new Map();
const cachedEncode = (url) => {
let encoded = qrCache.get(url);
if (!encoded) {
if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value);
encoded = Promise.resolve().then(() => encodeQr(url));
qrCache.set(url, encoded);
}
return encoded;
};
return async (endpoint, payload, signal) => {
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
if (!WECOM_RPC_ENDPOINTS.includes(endpoint)) {
return { ok: false, error: { code: 'bad-request', message: 'Unknown Enterprise WeChat endpoint.' } };
}
const invalid = payloadFailure(endpoint, payload);
if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } };
try {
let value;
if (endpoint === WECOM_ENDPOINTS.status) value = await publicStatus(await controller.status(), cachedEncode);
else if (endpoint === WECOM_ENDPOINTS.beginProvisioning) {
value = await withEncodedQr(await controller.startProvisioning(), cachedEncode);
} else if (endpoint === WECOM_ENDPOINTS.pollProvisioning) {
const current = await controller.registrationStatus(payload.attemptId);
if (!current) return { ok: false, error: { code: 'bad-request', message: 'The provisioning attempt no longer exists.' } };
value = await withEncodedQr(current, cachedEncode);
} else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) {
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
} else if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: true, value };
} catch {
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: false, error: { code: 'wecom-operation-failed', message: '企业微信操作失败,请稍后重试。' } };
}
};
}
export function installWecomRpc(ctx, controller, options) {
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
throw new TypeError('DSH Host Connection RPC is required');
}
return ctx.connection.rpc.handle(
WECOM_RPC_CHANNEL,
createWecomRpcHandler(controller, options),
{ authority: 'loopback' },
);
}

View file

@ -1,6 +1,7 @@
import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
import { apply as applyFeishu } from './channels/feishu/index.mjs';
import { apply as applyQq } from './channels/qq/index.mjs';
import { apply as applyWecom } from './channels/wecom/index.mjs';
import { apply as applyWeixin } from './channels/weixin/index.mjs';
export const name = 'dsh-im-host';
@ -10,6 +11,7 @@ export function createImHostPlugin(internals = {}) {
const startFeishu = internals.applyFeishu ?? applyFeishu;
const startWeixin = internals.applyWeixin ?? applyWeixin;
const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
const startWecom = internals.applyWecom ?? applyWecom;
const startQq = internals.applyQq ?? applyQq;
return Object.freeze({
name,
@ -18,6 +20,7 @@ export function createImHostPlugin(internals = {}) {
await startFeishu(ctx, config.feishu ?? {});
await startWeixin(ctx, config.weixin ?? {});
await startDingtalk(ctx, config.dingtalk ?? {});
await startWecom(ctx, config.wecom ?? {});
await startQq(ctx, config.qq ?? {});
},
});

View file

@ -14,10 +14,12 @@ const required = [
'plugin-src/host/channels/weixin/index.mjs',
'plugin-src/host/channels/dingtalk/index.mjs',
'plugin-src/host/channels/qq/index.mjs',
'plugin-src/host/channels/wecom/index.mjs',
'src/channels/feishu/feishu-runtime.mjs',
'src/channels/weixin/weixin-runtime.mjs',
'src/channels/dingtalk/dingtalk-runtime.mjs',
'src/channels/qq/qq-runtime.mjs',
'src/channels/wecom/wecom-runtime.mjs',
];
await Promise.all(required.map((path) => access(resolve(root, path))));
@ -49,7 +51,7 @@ if (!client.includes('container-type: inline-size')
|| !client.includes('@container (max-width: 680px)')) {
throw new Error('client bundle does not contain the narrow-panel DingTalk QR layout');
}
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/qq']) {
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq']) {
if (!host.includes(marker)) {
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
}
@ -75,6 +77,7 @@ const directDependencies = {
'dingtalk-stream': '2.1.4',
'@tencent-connect/qqbot-connector': '1.2.0',
'@tencent-connect/qqbot-nodejs': '1.0.4',
'@wecom/aibot-node-sdk': '1.0.7',
qrcode: '1.5.4',
};
for (const [name, version] of Object.entries(directDependencies)) {

View file

@ -0,0 +1,170 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function safeIntegrationId(value) {
const id = cleanString(value);
return id && /^wecom_[a-f0-9]{24}$/.test(id) ? id : null;
}
function safeSecretRef(value) {
const ref = cleanString(value);
return ref && /^DSH_WECOM_BOT_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null;
}
export function deriveWecomBotIdentity(remoteBotId) {
const raw = cleanString(remoteBotId);
if (!raw) throw new TypeError('Enterprise WeChat bot ID is required');
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
return {
botId: `wecom_${digest}`,
secretRef: `DSH_WECOM_BOT_SECRET_${digest.toUpperCase()}`,
};
}
export function maskWecomBotId(remoteBotId) {
const value = cleanString(remoteBotId) ?? '';
if (!value) return '企业微信机器人';
if (value.length <= 10) return `${value.slice(0, 3)}•••`;
return `${value.slice(0, 6)}••••${value.slice(-4)}`;
}
function normalizeBot(value) {
if (!value || typeof value !== 'object') return null;
const botId = safeIntegrationId(value.botId);
const remoteBotId = cleanString(value.remoteBotId);
const secretRef = safeSecretRef(value.secretRef);
if (!botId || !remoteBotId || !secretRef) return null;
const derived = deriveWecomBotIdentity(remoteBotId);
if (derived.botId !== botId || derived.secretRef !== secretRef) return null;
return Object.freeze({
botId,
remoteBotId,
secretRef,
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
connectedAt: cleanString(value.connectedAt),
});
}
function normalizeDocument(value) {
if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null;
const bots = value.bots.map(normalizeBot);
if (bots.some((bot) => bot === null)) return null;
const ids = new Set();
const remoteIds = new Set();
const refs = new Set();
for (const bot of bots) {
if (ids.has(bot.botId) || remoteIds.has(bot.remoteBotId) || refs.has(bot.secretRef)) return null;
ids.add(bot.botId);
remoteIds.add(bot.remoteBotId);
refs.add(bot.secretRef);
}
return Object.freeze({ version: 1, bots: Object.freeze(bots) });
}
export class WecomConfigStore {
#path;
#value = EMPTY_DOCUMENT;
#writeQueue = Promise.resolve();
constructor(path) {
this.#path = path;
}
async load() {
try {
const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
if (!normalized) throw new Error('dsh-im Enterprise WeChat config contains invalid bot data');
this.#value = normalized;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#value = EMPTY_DOCUMENT;
}
return this;
}
list() {
return structuredClone(this.#value.bots);
}
get(botId) {
const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
return bot ? structuredClone(bot) : null;
}
getByRemoteBotId(remoteBotId) {
const bot = this.#value.bots.find((candidate) => candidate.remoteBotId === remoteBotId);
return bot ? structuredClone(bot) : null;
}
async save(value) {
const normalized = normalizeBot(value);
if (!normalized) throw new Error('Refusing to persist incomplete Enterprise WeChat bot data');
return this.#mutate((bots) => {
const remoteCollision = bots.find(
(bot) => bot.remoteBotId === normalized.remoteBotId && bot.botId !== normalized.botId,
);
const refCollision = bots.find(
(bot) => bot.secretRef === normalized.secretRef && bot.botId !== normalized.botId,
);
if (remoteCollision || refCollision) throw new Error('Duplicate Enterprise WeChat bot identity');
const index = bots.findIndex((bot) => bot.botId === normalized.botId);
if (index === -1) bots.push(normalized);
else bots[index] = normalized;
return structuredClone(normalized);
});
}
async remove(botId) {
if (!safeIntegrationId(botId)) throw new TypeError('Invalid Enterprise WeChat bot ID');
return this.#mutate((bots) => {
const index = bots.findIndex((bot) => bot.botId === botId);
if (index === -1) return null;
const [removed] = bots.splice(index, 1);
return structuredClone(removed);
});
}
async clear() {
const operation = this.#writeQueue.then(async () => {
try {
await unlink(this.#path);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
this.#value = EMPTY_DOCUMENT;
});
this.#writeQueue = operation.then(() => undefined, () => undefined);
await operation;
}
async #mutate(mutator) {
let result;
const operation = this.#writeQueue.then(async () => {
const bots = [...this.#value.bots];
result = mutator(bots);
const document = Object.freeze({ version: 1, bots: Object.freeze(bots) });
await this.#write(document);
this.#value = document;
});
this.#writeQueue = operation.then(() => undefined, () => undefined);
await operation;
return result;
}
async #write(document) {
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
await rename(temporary, this.#path);
}
}

View file

@ -0,0 +1,3 @@
import { HarnessClient } from '../weixin/harness-client.mjs';
export class WecomHarnessClient extends HarnessClient {}

View file

@ -0,0 +1,95 @@
const GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate';
const POLL_URL = 'https://work.weixin.qq.com/ai/qc/query_result';
const QR_TTL_MS = 5 * 60_000;
const POLL_INTERVAL_MS = 3_000;
function defaultPlatform() {
if (process.platform === 'win32') return 2;
if (process.platform === 'linux') return 3;
return 1;
}
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function safeVerificationUrl(value) {
const raw = cleanString(value);
if (!raw) return null;
try {
const url = new URL(raw);
return url.protocol === 'https:' && url.hostname === 'work.weixin.qq.com' && (!url.port || url.port === '443')
? url.href : null;
} catch {
return null;
}
}
function combinedSignal(signal, timeoutMs) {
return signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
}
async function requestJson(fetchImpl, url, signal) {
const response = await fetchImpl(url, {
method: 'GET',
redirect: 'error',
signal: combinedSignal(signal, 10_000),
headers: { accept: 'application/json' },
});
if (!response.ok) throw new Error(`Enterprise WeChat QR service returned HTTP ${response.status}`);
return response.json();
}
export class WecomQrAuth {
#fetch;
#clock;
#source;
#platform;
constructor({
fetch: fetchImpl = globalThis.fetch,
clock = () => Date.now(),
source = 'deepseek-harness',
platform = defaultPlatform(),
} = {}) {
if (typeof fetchImpl !== 'function') throw new TypeError('A fetch implementation is required');
this.#fetch = fetchImpl;
this.#clock = clock;
this.#source = source;
this.#platform = [1, 2, 3].includes(platform) ? platform : defaultPlatform();
}
async start({ signal } = {}) {
const url = new URL(GENERATE_URL);
url.searchParams.set('source', this.#source);
url.searchParams.set('plat', String(this.#platform));
const body = await requestJson(this.#fetch, url, signal);
const scode = cleanString(body?.data?.scode);
const verificationUrl = safeVerificationUrl(body?.data?.auth_url);
if (!scode || !verificationUrl) throw new Error('Enterprise WeChat QR service returned invalid data');
return {
scode,
verificationUrl,
expiresAt: this.#clock() + QR_TTL_MS,
pollIntervalMs: POLL_INTERVAL_MS,
};
}
async poll({ scode, signal } = {}) {
const code = cleanString(scode);
if (!code) throw new TypeError('Enterprise WeChat QR poll code is required');
const url = new URL(POLL_URL);
url.searchParams.set('scode', code);
const body = await requestJson(this.#fetch, url, signal);
const state = cleanString(body?.data?.status)?.toLowerCase();
if (state === 'success') {
const remoteBotId = cleanString(body?.data?.bot_info?.botid);
const secret = cleanString(body?.data?.bot_info?.secret);
if (!remoteBotId || !secret) throw new Error('Enterprise WeChat QR result omitted bot credentials');
return { status: 'success', remoteBotId, secret };
}
if (['expired', 'timeout'].includes(state)) return { status: 'expired' };
if (['fail', 'failed', 'error'].includes(state)) return { status: 'failed' };
return { status: 'waiting' };
}
}

View file

@ -0,0 +1,90 @@
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] });
function normalizeState(value) {
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
const sessions = {};
if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) {
for (const [key, sessionId] of Object.entries(value.sessions)) {
if (typeof key === 'string' && typeof sessionId === 'string' && sessionId) sessions[key] = sessionId;
}
}
return {
version: 1,
sessions,
seenMessageIds: Array.isArray(value.seenMessageIds)
? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
: [],
};
}
export class WecomStateStore {
#path;
#state = structuredClone(EMPTY_STATE);
#writeQueue = Promise.resolve();
constructor(path) {
this.#path = path;
}
async load() {
try {
this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#state = structuredClone(EMPTY_STATE);
await this.#persist();
}
return this;
}
sessionFor(key) {
return this.#state.sessions[key] ?? null;
}
async setSession(key, sessionId) {
this.#state.sessions[key] = sessionId;
await this.#persist();
}
async clearSession(key) {
delete this.#state.sessions[key];
await this.#persist();
}
hasSeen(messageId) {
return this.#state.seenMessageIds.includes(messageId);
}
async markSeen(messageId) {
if (this.hasSeen(messageId)) return;
this.#state.seenMessageIds.push(messageId);
if (this.#state.seenMessageIds.length > 1_000) {
this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000);
}
await this.#persist();
}
async remove() {
try {
await unlink(this.#path);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
this.#state = structuredClone(EMPTY_STATE);
}
async #persist() {
const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
const operation = this.#writeQueue.then(async () => {
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
await rename(temporary, this.#path);
});
this.#writeQueue = operation.then(() => undefined, () => undefined);
await operation;
}
}

View file

@ -0,0 +1,243 @@
import { generateReqId } from '@wecom/aibot-node-sdk';
const HELP_TEXT = [
'企业微信机器人已连接 DeepSeek Harness。',
'',
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
const MAX_REPLY_BYTES = 18_000;
function bodyOf(frame) {
return frame?.body && typeof frame.body === 'object' ? frame.body : {};
}
function conversationKey(frame) {
const body = bodyOf(frame);
return body.chattype === 'group' ? `group:${body.chatid}` : `direct:${body.from?.userid}`;
}
function messageText(frame) {
const body = bodyOf(frame);
if (body.msgtype === 'text') return typeof body.text?.content === 'string' ? body.text.content.trim() : '';
if (body.msgtype === 'voice') return typeof body.voice?.content === 'string' ? body.voice.content.trim() : '';
if (body.msgtype === 'mixed' && Array.isArray(body.mixed?.msg_item)) {
return body.mixed.msg_item
.filter((item) => item?.msgtype === 'text' && typeof item.text?.content === 'string')
.map((item) => item.text.content)
.join('\n')
.trim();
}
return '';
}
function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
const source = String(text ?? '').trim();
if (!source) return [];
const chunks = [];
let current = '';
let bytes = 0;
for (const character of source) {
const size = Buffer.byteLength(character);
if (current && bytes + size > maxBytes) {
chunks.push(current);
current = character;
bytes = size;
} else {
current += character;
bytes += size;
}
}
if (current) chunks.push(current);
return chunks;
}
function progressText(update) {
if (update?.type === 'text') return update.text;
if (update?.type === 'tool') return `正在使用${update.name}…`;
return update?.text;
}
export function createWecomBridgeStatus() {
return {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
};
}
export class WecomHarnessBridge {
#client;
#harness;
#state;
#status;
#logger;
#replyTimeoutMs;
#generateReqId;
#queues = new Map();
constructor({
client,
harness,
state,
status = createWecomBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
generateStreamId = generateReqId,
}) {
if (!client || typeof client.replyStream !== 'function' || typeof client.sendMessage !== 'function') {
throw new TypeError('Enterprise WeChat client is required');
}
if (!harness || !state) throw new TypeError('Harness client and state store are required');
this.#client = client;
this.#harness = harness;
this.#state = state;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#generateReqId = generateStreamId;
}
get status() {
return structuredClone(this.#status);
}
accept(frame) {
const key = conversationKey(frame);
const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous
.catch(() => undefined)
.then(() => this.#process(frame))
.finally(() => {
if (this.#queues.get(key) === current) this.#queues.delete(key);
});
this.#queues.set(key, current);
return current;
}
async waitForIdle() {
await Promise.allSettled([...this.#queues.values()]);
}
async #sendActive(chatId, text) {
for (const chunk of splitUtf8(text)) {
await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
}
}
async #sendImmediate(frame, chatId, text) {
const chunks = splitUtf8(text);
if (chunks.length === 0) return;
try {
await this.#client.replyStream(frame, this.#generateReqId('stream'), chunks[0], true);
for (const chunk of chunks.slice(1)) {
await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
}
} catch {
await this.#sendActive(chatId, text);
}
}
async #process(frame) {
const body = bodyOf(frame);
const messageId = typeof body.msgid === 'string' ? body.msgid : '';
const senderId = typeof body.from?.userid === 'string' ? body.from.userid : '';
const chatId = body.chattype === 'group' ? body.chatid : senderId;
if (!messageId || !senderId || !chatId || !['single', 'group'].includes(body.chattype)) return;
if (this.#state.hasSeen(messageId)) return;
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString();
const text = messageText(frame);
let streamId = null;
let streamStarted = false;
try {
if (!text) {
await this.#sendImmediate(frame, chatId, '目前支持文字、语音转写和图文混排中的文字消息。');
await this.#state.markSeen(messageId);
return;
}
const command = text.toLowerCase();
if (command === '/help') {
await this.#sendImmediate(frame, chatId, HELP_TEXT);
await this.#state.markSeen(messageId);
return;
}
if (command === '/status') {
await this.#harness.ensureRunning();
await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
await this.#state.markSeen(messageId);
return;
}
const key = conversationKey(frame);
if (command === '/new') {
await this.#state.clearSession(key);
await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
await this.#state.markSeen(messageId);
return;
}
let sessionId = this.#state.sessionFor(key);
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
sessionId = await this.#harness.createSession();
await this.#state.setSession(key, sessionId);
}
streamId = this.#generateReqId('stream');
try {
await this.#client.replyStream(frame, streamId, '正在思考中…', false);
streamStarted = true;
} catch (error) {
this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
}
const answer = await this.#harness.ask(sessionId, text, {
timeoutMs: this.#replyTimeoutMs,
onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
? async (update) => {
const progress = splitUtf8(progressText(update))[0];
if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false);
}
: undefined,
});
const chunks = splitUtf8(answer || '任务已完成,但没有生成可显示的文本。');
let finalSent = false;
if (streamStarted && chunks.length > 0) {
try {
await this.#client.replyStream(frame, streamId, chunks[0], true);
for (const chunk of chunks.slice(1)) {
await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
}
finalSent = true;
} catch (error) {
this.#logger.warn?.('[dsh-im:wecom] stream finalization failed; using an active reply:', error);
}
}
if (!finalSent) await this.#sendActive(chatId, answer);
await this.#state.markSeen(messageId);
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
} catch (error) {
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
try {
if (streamStarted && streamId) {
await this.#client.replyStream(frame, streamId, '消息处理失败,请稍后重试。', true);
} else {
await this.#sendImmediate(frame, chatId, '消息处理失败,请稍后重试。');
}
await this.#state.markSeen(messageId);
} catch {
this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply');
}
}
}
}

View file

@ -0,0 +1,427 @@
import { randomUUID } from 'node:crypto';
import { deriveWecomBotIdentity, maskWecomBotId } from './config-store.mjs';
const ACTIVE_ATTEMPT_STATES = new Set(['pending', 'connecting']);
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled', 'expired']);
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function safeError(code, message) {
return Object.freeze({ code, message });
}
function publicAttempt(record) {
if (!record) return null;
return {
attemptId: record.id,
status: record.state,
pollIntervalMs: record.pollIntervalMs,
qrRevision: record.qrRevision,
...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}),
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
...(record.botId ? { botId: record.botId } : {}),
...(record.error ? { error: structuredClone(record.error) } : {}),
};
}
export class WecomController {
#qrAuth;
#credentials;
#configStore;
#createRuntime;
#deleteState;
#logger;
#runtimes = new Map();
#errors = new Map();
#attempts = new Map();
#activeAttemptId = null;
#transitions = new Map();
#revision = 0;
#closed = false;
constructor({
qrAuth,
credentials,
configStore,
createRuntime,
deleteState = async () => {},
logger = console,
}) {
if (!qrAuth || typeof qrAuth.start !== 'function' || typeof qrAuth.poll !== 'function') {
throw new TypeError('Enterprise WeChat QR auth is required');
}
if (!credentials || typeof credentials.resolve !== 'function'
|| typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
throw new TypeError('WecomController requires the DSH credential provider');
}
if (!configStore || typeof configStore.list !== 'function'
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
throw new TypeError('WecomController requires a config store');
}
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
this.#qrAuth = qrAuth;
this.#credentials = credentials;
this.#configStore = configStore;
this.#createRuntime = createRuntime;
this.#deleteState = deleteState;
this.#logger = logger;
}
async initialize() {
if (this.#closed) return this.status();
for (const config of this.#configStore.list()) {
await this.#withBotTransition(config.botId, async () => {
const existing = this.#runtimes.get(config.botId)?.status;
if (this.#closed || existing?.ready || existing?.wecomConnectionState === 'connecting') return;
const secret = await this.#resolveSecret(config.secretRef);
if (!secret) {
this.#errors.set(config.botId, safeError('missing-secret', '企业微信机器人凭据缺失,请移除后重新扫码。'));
return;
}
try {
await this.#startRuntime(config, secret);
this.#errors.delete(config.botId);
} catch (error) {
this.#errors.set(config.botId, safeError('connection-failed', '企业微信连接未就绪,插件会自动重试。'));
this.#logger.warn?.(`[dsh-im:wecom] bot ${config.botId} failed to initialize`);
} finally {
this.#touch();
}
});
}
return this.status();
}
async startProvisioning() {
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
const record = {
id: randomUUID(),
state: 'pending',
createdAt: Date.now(),
expiresAt: null,
pollIntervalMs: 3_000,
qrRevision: 1,
verificationUrl: null,
scode: null,
botId: null,
error: null,
controller: new AbortController(),
polling: null,
transition: null,
};
this.#attempts.set(record.id, record);
this.#activeAttemptId = record.id;
this.#touch();
try {
const started = await this.#qrAuth.start({ signal: record.controller.signal });
record.scode = cleanString(started.scode);
record.verificationUrl = cleanString(started.verificationUrl);
record.expiresAt = Number(started.expiresAt);
record.pollIntervalMs = Math.min(10_000, Math.max(500, Number(started.pollIntervalMs) || 3_000));
if (!record.scode || !record.verificationUrl || !Number.isFinite(record.expiresAt)) {
throw new Error('Enterprise WeChat QR auth returned incomplete data');
}
this.#touch();
return publicAttempt(record);
} catch (error) {
record.state = record.controller.signal.aborted ? 'cancelled' : 'failed';
record.error = record.controller.signal.aborted
? safeError('cancelled', '扫码绑定已取消。')
: safeError('qr-start-failed', '无法生成企业微信二维码,请稍后重试。');
this.#finishAttempt(record);
throw error;
}
}
async registrationStatus(attemptId) {
const record = this.#attempts.get(attemptId);
if (!record || TERMINAL_ATTEMPT_STATES.has(record.state)) return publicAttempt(record);
if (record.state === 'connecting') {
await record.transition?.catch(() => undefined);
return publicAttempt(record);
}
if (Date.now() >= record.expiresAt) {
record.state = 'expired';
record.error = safeError('expired', '企业微信二维码已过期,请重新生成。');
record.controller.abort();
this.#finishAttempt(record);
return publicAttempt(record);
}
if (!record.polling) {
const polling = this.#pollAttempt(record).finally(() => {
if (record.polling === polling) record.polling = null;
});
record.polling = polling;
}
await record.polling.catch(() => undefined);
return publicAttempt(record);
}
async cancelProvisioning(attemptId) {
const record = this.#attempts.get(attemptId);
if (!record) return null;
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
record.controller.abort();
await Promise.allSettled([record.polling, record.transition].filter(Boolean));
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
record.error ??= safeError('cancelled', '扫码绑定已取消。');
this.#finishAttempt(record);
}
return publicAttempt(record);
}
async reconnectBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown Enterprise WeChat bot');
await this.#withBotTransition(botId, async () => {
const secret = await this.#resolveSecret(config.secretRef);
if (!secret) throw new Error('Enterprise WeChat bot secret is missing');
try {
await this.#startRuntime(config, secret);
this.#errors.delete(botId);
} catch (error) {
this.#errors.set(botId, safeError('connection-failed', '企业微信连接仍未就绪,请稍后重试。'));
throw error;
} finally {
this.#touch();
}
});
return this.status();
}
async deleteBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown Enterprise WeChat bot');
await this.#withBotTransition(botId, async () => {
const previous = await this.#credentials.resolve(config.secretRef).catch(() => undefined);
await this.#stopRuntime(botId);
try {
await this.#credentials.unset(config.secretRef);
await this.#configStore.remove(botId);
} catch (error) {
if (previous?.value) {
await this.#credentials.set(config.secretRef, previous.value).catch(() => undefined);
await this.#startRuntime(config, previous.value).catch(() => undefined);
}
throw new Error('Unable to remove the Enterprise WeChat bot safely.', { cause: error });
}
await this.#deleteState({ botId, config }).catch((error) => {
this.#logger.warn?.(`[dsh-im:wecom] bot ${botId} state cleanup failed:`, error);
});
this.#errors.delete(botId);
this.#touch();
});
return this.status();
}
status() {
const bots = this.#configStore.list().map((config) => {
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
const connected = runtimeStatus?.ready === true
&& runtimeStatus.wecomConnectionState === 'connected'
&& runtimeStatus.harnessReachable === true;
const state = connected ? 'connected'
: runtimeStatus?.wecomConnectionState === 'connecting' ? 'connecting'
: this.#errors.has(config.botId) || runtimeStatus?.wecomConnectionState === 'failed'
? 'error' : 'offline';
return {
botId: config.botId,
state,
connected,
configured: true,
bot: { name: '企业微信机器人', appIdMasked: maskWecomBotId(config.remoteBotId) },
health: {
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
summary: connected ? '企业微信 WebSocket 长连接运行正常'
: state === 'error' ? '企业微信连接未就绪,插件会自动重试' : '企业微信连接当前离线',
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
},
stats: {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
error: structuredClone(this.#errors.get(config.botId) ?? null),
};
});
const connectedCount = bots.filter((bot) => bot.connected).length;
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
return {
schemaVersion: 1,
revision: this.#revision,
state: active && ACTIVE_ATTEMPT_STATES.has(active.state) ? 'provisioning'
: bots.length === 0 ? 'disconnected'
: connectedCount === bots.length ? 'connected'
: connectedCount > 0 ? 'degraded' : 'offline',
bots,
totals: { configured: bots.length, connected: connectedCount },
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
? { provisioning: publicAttempt(active) } : {}),
};
}
async close() {
if (this.#closed) return;
this.#closed = true;
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
}
async #pollAttempt(record) {
try {
const result = await this.#qrAuth.poll({ scode: record.scode, signal: record.controller.signal });
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
if (result.status === 'waiting') return;
if (result.status === 'expired') {
record.state = 'expired';
record.error = safeError('expired', '企业微信二维码已过期,请重新生成。');
this.#finishAttempt(record);
return;
}
if (result.status !== 'success') {
record.state = 'failed';
record.error = safeError('qr-connect-failed', '企业微信扫码没有完成,请重新生成二维码。');
this.#finishAttempt(record);
return;
}
record.state = 'connecting';
record.verificationUrl = null;
record.expiresAt = null;
record.scode = null;
this.#touch();
const transition = this.#completeProvisioning(record, result);
record.transition = transition;
await transition;
} catch (error) {
if (record.controller.signal.aborted) return;
record.state = 'failed';
record.error = safeError('qr-connect-failed', '企业微信扫码服务暂时不可用,请重新生成二维码。');
this.#logger.warn?.('[dsh-im:wecom] QR polling failed');
this.#finishAttempt(record);
}
}
async #completeProvisioning(record, result) {
try {
const remoteBotId = cleanString(result.remoteBotId);
const secret = cleanString(result.secret);
if (!remoteBotId || !secret) throw new Error('Enterprise WeChat authorization returned incomplete credentials');
record.botId = await this.#activateBot(record, { remoteBotId, secret });
record.state = 'connected';
record.error = null;
} catch (error) {
if (record.controller.signal.aborted) {
record.state = 'cancelled';
record.error = safeError('cancelled', '扫码绑定已取消。');
} else {
record.state = 'failed';
record.error = safeError('activation-failed', '企业微信已授权,但无法安全保存接入配置。');
this.#logger.error?.('[dsh-im:wecom] provisioning failed');
}
} finally {
this.#finishAttempt(record);
}
}
async #activateBot(record, { remoteBotId, secret }) {
const identity = deriveWecomBotIdentity(remoteBotId);
const previousConfig = this.#configStore.getByRemoteBotId(remoteBotId);
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
const config = {
botId: identity.botId,
remoteBotId,
secretRef: identity.secretRef,
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
connectedAt: new Date().toISOString(),
};
return this.#withBotTransition(identity.botId, async () => {
await this.#credentials.set(identity.secretRef, secret);
try {
if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError');
await this.#configStore.save(config);
} catch (error) {
await this.#restoreCredential(identity.secretRef, previousSecret);
throw error;
}
try {
if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError');
await this.#startRuntime(config, secret);
this.#errors.delete(identity.botId);
} catch (error) {
if (record.controller.signal.aborted) {
await this.#stopRuntime(identity.botId);
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
else await this.#configStore.remove(identity.botId).catch(() => undefined);
await this.#restoreCredential(identity.secretRef, previousSecret);
throw error;
}
this.#errors.set(identity.botId, safeError('connection-failed', '企业微信机器人已绑定,消息连接暂未就绪。'));
this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} activation connection failed`);
}
this.#touch();
return identity.botId;
});
}
async #startRuntime(config, secret) {
await this.#stopRuntime(config.botId);
const runtime = await this.#createRuntime({ botId: config.botId, config, secret });
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
throw new TypeError('createRuntime returned an invalid Enterprise WeChat runtime');
}
this.#runtimes.set(config.botId, runtime);
try {
await runtime.start();
} catch (error) {
await runtime.stop().catch(() => undefined);
this.#runtimes.delete(config.botId);
throw error;
}
}
async #stopRuntime(botId) {
const runtime = this.#runtimes.get(botId);
this.#runtimes.delete(botId);
await runtime?.stop().catch((error) => {
this.#logger.warn?.(`[dsh-im:wecom] bot ${botId} failed to stop cleanly:`, error);
});
}
async #resolveSecret(ref) {
const result = await this.#credentials.resolve(ref).catch(() => undefined);
return cleanString(result?.value);
}
async #restoreCredential(ref, previous) {
if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
else await this.#credentials.unset(ref).catch(() => undefined);
}
#withBotTransition(botId, operation) {
const previous = this.#transitions.get(botId) ?? Promise.resolve();
const current = previous.catch(() => undefined).then(operation);
const settled = current.finally(() => {
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
});
this.#transitions.set(botId, settled);
return settled;
}
#finishAttempt(record) {
record.scode = null;
record.verificationUrl = null;
record.expiresAt = null;
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch();
const terminal = [...this.#attempts.values()].filter((attempt) => TERMINAL_ATTEMPT_STATES.has(attempt.state));
while (terminal.length > 16) this.#attempts.delete(terminal.shift().id);
}
#touch() {
this.#revision += 1;
}
}

View file

@ -0,0 +1,209 @@
import { WSAuthFailureError, WSClient, WSReconnectExhaustedError } from '@wecom/aibot-node-sdk';
import { createWecomBridgeStatus, WecomHarnessBridge } from './wecom-bridge.mjs';
function timeoutError() {
const error = new Error('Enterprise WeChat WebSocket authentication timed out');
error.code = 'connect-timeout';
return error;
}
export function createWecomRuntimeStatus() {
return {
startedAt: null,
ready: false,
wecomConnectionState: 'idle',
harnessReachable: false,
lastCheckedAt: null,
lastConnectedAt: null,
lastError: null,
...createWecomBridgeStatus(),
};
}
export class WecomRuntime {
#config;
#secret;
#harness;
#state;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
#maxReconnectAttempts;
#createClient;
#status = createWecomRuntimeStatus();
#client = null;
#bridge = null;
#starting = null;
#startController = null;
constructor({
config,
secret,
harness,
state,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
maxReconnectAttempts = 10,
createClient = (options) => new WSClient(options),
}) {
if (!config || !secret || !harness || !state) {
throw new TypeError('WecomRuntime requires config, secret, Harness, and state');
}
this.#config = config;
this.#secret = secret;
this.#harness = harness;
this.#state = state;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
this.#maxReconnectAttempts = maxReconnectAttempts;
this.#createClient = createClient;
}
get status() {
return structuredClone(this.#status);
}
async start() {
if (this.#status.ready && this.#client) return this.status;
if (this.#starting) return this.#starting;
const controller = new AbortController();
this.#startController = controller;
this.#starting = this.#start(controller.signal).finally(() => {
if (this.#startController === controller) this.#startController = null;
this.#starting = null;
});
return this.#starting;
}
async #start(signal) {
await this.#stopActive();
signal.throwIfAborted();
this.#status.startedAt = new Date().toISOString();
this.#status.wecomConnectionState = 'connecting';
this.#status.lastError = null;
await this.#harness.ensureRunning();
this.#status.harnessReachable = true;
const silentSdkLogger = { debug() {}, info() {}, warn() {}, error() {} };
const client = this.#createClient({
botId: this.#config.remoteBotId,
secret: this.#secret,
logger: silentSdkLogger,
maxReconnectAttempts: this.#maxReconnectAttempts,
});
if (!client || typeof client.connect !== 'function' || typeof client.disconnect !== 'function') {
throw new TypeError('Enterprise WeChat client factory returned an invalid client');
}
this.#client = client;
this.#bridge = new WecomHarnessBridge({
client,
harness: this.#harness,
state: this.#state,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
});
let readyResolve;
let readyReject;
let authenticated = false;
const ready = new Promise((resolve, reject) => {
readyResolve = resolve;
readyReject = reject;
});
const onAuthenticated = () => {
if (this.#client !== client || signal.aborted) return;
authenticated = true;
const now = Date.now();
this.#status.ready = true;
this.#status.wecomConnectionState = 'connected';
this.#status.lastCheckedAt = now;
this.#status.lastConnectedAt = now;
this.#status.lastError = null;
readyResolve();
};
const onDisconnected = () => {
if (this.#client !== client) return;
this.#status.ready = false;
this.#status.wecomConnectionState = 'connecting';
this.#status.lastCheckedAt = Date.now();
};
const onReconnecting = () => {
if (this.#client !== client) return;
this.#status.ready = false;
this.#status.wecomConnectionState = 'connecting';
this.#status.lastCheckedAt = Date.now();
};
const onError = (error) => {
if (this.#client !== client) return;
const terminal = error instanceof WSAuthFailureError || error instanceof WSReconnectExhaustedError;
if (!authenticated && terminal) readyReject(error);
if (terminal) {
this.#status.ready = false;
this.#status.wecomConnectionState = 'failed';
}
this.#status.lastError = terminal ? error.name : 'connection-error';
this.#logger.warn?.(`[dsh-im:wecom] bot ${this.#config.botId} connection error`);
};
const onMessage = (frame) => this.#bridge?.accept(frame);
client.on('authenticated', onAuthenticated);
client.on('disconnected', onDisconnected);
client.on('reconnecting', onReconnecting);
client.on('error', onError);
client.on('message', onMessage);
let timer;
try {
client.connect();
await Promise.race([
ready,
new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}),
new Promise((_, reject) => {
timer = setTimeout(() => reject(timeoutError()), this.#connectTimeoutMs);
}),
]);
return this.status;
} catch (error) {
if (signal.aborted) {
await this.#stopActive();
throw error;
}
this.#status.ready = false;
this.#status.wecomConnectionState = 'failed';
this.#status.lastError = error?.message ?? String(error);
await this.#stopActive();
throw error;
} finally {
clearTimeout(timer);
}
}
async stop() {
const starting = this.#starting;
this.#startController?.abort(new DOMException('Enterprise WeChat runtime stopped', 'AbortError'));
await this.#stopActive();
await starting?.catch(() => undefined);
return this.status;
}
async #stopActive() {
const client = this.#client;
const bridge = this.#bridge;
this.#client = null;
this.#bridge = null;
try {
client?.disconnect();
client?.removeAllListeners?.();
} catch (error) {
this.#logger.warn?.(`[dsh-im:wecom] bot ${this.#config.botId} failed to stop cleanly`);
}
await bridge?.waitForIdle();
this.#status.ready = false;
this.#status.wecomConnectionState = 'idle';
}
}

View file

@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { WecomHarnessBridge } from '../../../src/channels/wecom/wecom-bridge.mjs';
function frame(overrides = {}) {
return {
headers: { req_id: 'req-1' },
body: {
msgid: 'msg-1',
chattype: 'single',
from: { userid: 'member-1' },
msgtype: 'text',
text: { content: '请回答' },
...overrides,
},
};
}
function state() {
const seen = new Set();
return {
seen,
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: () => 'session-existing',
sessionExists: async () => true,
setSession: async () => {},
clearSession: async () => {},
};
}
test('Enterprise WeChat messages stream Harness progress and finalize once', async () => {
const replies = [];
const active = [];
const store = state();
const bridge = new WecomHarnessBridge({
client: {
replyStream: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }),
replyStreamNonBlocking: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }),
sendMessage: async (chatId, body) => active.push({ chatId, body }),
},
generateStreamId: () => 'stream-1',
harness: {
sessionExists: async () => true,
createSession: async () => 'session-new',
ensureRunning: async () => true,
ask: async (_session, _text, { onUpdate }) => {
await onUpdate({ type: 'tool', name: '网页搜索' });
await onUpdate({ type: 'text', text: '回答中' });
return '最终回答';
},
},
state: store,
});
await bridge.accept(frame());
assert.deepEqual(replies, [
{ streamId: 'stream-1', content: '正在思考中…', finish: false },
{ streamId: 'stream-1', content: '正在使用网页搜索…', finish: false },
{ streamId: 'stream-1', content: '回答中', finish: false },
{ streamId: 'stream-1', content: '最终回答', finish: true },
]);
assert.deepEqual(active, []);
assert.equal(store.seen.has('msg-1'), true);
assert.equal(bridge.status.messagesReplied, 1);
});
test('Enterprise WeChat visibility scope accepts direct and group conversations without local approval', async () => {
let asks = 0;
const client = {
replyStream: async () => {},
replyStreamNonBlocking: async () => {},
sendMessage: async () => {},
};
const harness = {
sessionExists: async () => true,
ask: async () => { asks += 1; return 'ok'; },
};
const bridge = new WecomHarnessBridge({ client, harness, state: state(), generateStreamId: () => 'stream' });
await bridge.accept(frame({ msgid: 'direct', from: { userid: 'member-a' } }));
await bridge.accept(frame({ msgid: 'group', chattype: 'group', chatid: 'group-1', from: { userid: 'member-b' } }));
assert.equal(asks, 2);
});
test('Enterprise WeChat finalizes an existing progress stream when Harness fails', async () => {
const replies = [];
const store = state();
const bridge = new WecomHarnessBridge({
client: {
replyStream: async (_frame, streamId, content, finish) => replies.push({ streamId, content, finish }),
replyStreamNonBlocking: async () => {},
sendMessage: async () => {},
},
generateStreamId: () => 'stream-failure',
harness: {
sessionExists: async () => true,
ensureRunning: async () => true,
ask: async () => { throw new Error('Harness unavailable'); },
},
state: store,
logger: { error() {} },
});
await bridge.accept(frame());
assert.deepEqual(replies, [
{ streamId: 'stream-failure', content: '正在思考中…', finish: false },
{ streamId: 'stream-failure', content: '消息处理失败,请稍后重试。', finish: true },
]);
assert.equal(store.seen.has('msg-1'), true);
});

View file

@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeProvisioning,
normalizeSnapshot,
safeQrSource,
} from '../../../plugin-src/client/channels/wecom/api.js';
test('Enterprise WeChat client keeps only redacted bot and Host-rendered QR state', () => {
const qr = 'data:image/png;base64,YWJjZA==';
assert.equal(safeQrSource(qr), qr);
const provision = normalizeProvisioning({
attemptId: 'attempt_1', status: 'pending', expiresAt: Date.now() + 1_000, qrCodeDataUrl: qr,
});
assert.equal(provision.qrCodeDataUrl, qr);
const snapshot = normalizeSnapshot({
bots: [{
botId: 'wecom_abc', connected: true, state: 'connected',
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
health: { summary: '运行正常' },
}],
});
assert.equal(snapshot.totals.connected, 1);
assert.equal(snapshot.bots[0].bot.appIdMasked, 'bot••••001');
});

View file

@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
AccountCard,
WecomSettingsTab,
} from '../../../plugin-src/client/channels/wecom/index.js';
test('Enterprise WeChat settings uses the shared compact channel toolbar', () => {
const markup = renderToStaticMarkup(React.createElement(WecomSettingsTab, {
rpcCall: async () => ({ ok: true, value: {} }),
}));
assert.match(markup, /class="ddt-page dwecom-page dim-channelPage"/);
assert.match(markup, /class="ddt-button dim-scanButton"/);
assert.match(markup, />扫码绑定企业微信机器人</);
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
});
test('Enterprise WeChat bot cards match the shared two-metric card treatment', () => {
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
botId: 'wecom_bot',
connected: true,
state: 'connected',
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
health: { summary: '企业微信 WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
error: null,
},
onReconnect() {},
onRequestRemove() {},
onConfirmRemove() {},
onCancelRemove() {},
}));
assert.match(markup, /class="ddt-card dim-botCard"/);
assert.match(markup, /data-im-channel-logo="wecom"/);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.match(markup, />检查连接<[^]*>移除接入</);
assert.doesNotMatch(markup, /收到\s*\/\s*回复/);
});

View file

@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
deriveWecomBotIdentity,
WecomConfigStore,
} from '../../../src/channels/wecom/config-store.mjs';
import { WecomQrAuth } from '../../../src/channels/wecom/qr-auth.mjs';
test('Enterprise WeChat config stores only non-secret bot identity with mode 0600', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-wecom-config-'));
t.after(() => rm(root, { recursive: true, force: true }));
const path = join(root, 'config.json');
const identity = deriveWecomBotIdentity('bot-enterprise-1');
const store = await new WecomConfigStore(path).load();
await store.save({
...identity,
remoteBotId: 'bot-enterprise-1',
createdAt: '2026-08-15T00:00:00.000Z',
});
const document = await readFile(path, 'utf8');
assert.match(document, /bot-enterprise-1/);
assert.doesNotMatch(document, /private-secret|"secret"/);
assert.equal((await stat(path)).mode & 0o777, 0o600);
assert.equal(store.get(identity.botId).remoteBotId, 'bot-enterprise-1');
});
test('Enterprise WeChat QR auth uses fixed official endpoints and returns credentials only after success', async () => {
const requests = [];
const replies = [
{ data: { scode: 'opaque-scode', auth_url: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque' } },
{ data: { status: 'init' } },
{ data: { status: 'success', bot_info: { botid: 'bot-enterprise-1', secret: 'private-secret' } } },
];
const auth = new WecomQrAuth({
clock: () => 1_000,
fetch: async (url, options) => {
requests.push({ url: String(url), options });
return { ok: true, status: 200, json: async () => replies.shift() };
},
});
const started = await auth.start();
assert.equal(started.verificationUrl, 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque');
assert.equal(started.expiresAt, 301_000);
assert.equal(await auth.poll({ scode: started.scode }).then((value) => value.status), 'waiting');
const completed = await auth.poll({ scode: started.scode });
assert.deepEqual(completed, {
status: 'success', remoteBotId: 'bot-enterprise-1', secret: 'private-secret',
});
assert.match(requests[0].url, /^https:\/\/work\.weixin\.qq\.com\/ai\/qc\/generate\?/);
assert.match(requests[0].url, /source=deepseek-harness/);
assert.match(requests[1].url, /^https:\/\/work\.weixin\.qq\.com\/ai\/qc\/query_result\?/);
assert.equal(requests.every(({ options }) => options.redirect === 'error'), true);
});
test('Enterprise WeChat QR auth rejects an authorization URL outside the official host', async () => {
const auth = new WecomQrAuth({
fetch: async () => ({
ok: true,
status: 200,
json: async () => ({ data: { scode: 'opaque', auth_url: 'https://example.com/steal' } }),
}),
});
await assert.rejects(() => auth.start(), /invalid data/);
});

View file

@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { WecomController } from '../../../src/channels/wecom/wecom-controller.mjs';
import {
createWecomRpcHandler,
WECOM_ENDPOINTS,
} from '../../../plugin-src/host/channels/wecom/rpc.mjs';
test('Enterprise WeChat QR success stores Secret off-config and starts its runtime', async () => {
const values = new Map();
const configs = [];
let runtimeArgs;
const controller = new WecomController({
qrAuth: {
start: async () => ({
scode: 'host-only-code',
verificationUrl: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque',
expiresAt: Date.now() + 10_000,
pollIntervalMs: 500,
}),
poll: async () => ({ status: 'success', remoteBotId: 'remote-bot', secret: 'private-secret' }),
},
credentials: {
resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined,
set: async (ref, value) => values.set(ref, value),
unset: async (ref) => values.delete(ref),
},
configStore: {
list: () => [...configs],
get: (id) => configs.find((value) => value.botId === id) ?? null,
getByRemoteBotId: (id) => configs.find((value) => value.remoteBotId === id) ?? null,
save: async (value) => { configs.splice(0, configs.length, value); },
remove: async () => null,
},
createRuntime: async (args) => {
runtimeArgs = args;
return {
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
start: async () => {},
stop: async () => {},
};
},
});
const started = await controller.startProvisioning();
const completed = await controller.registrationStatus(started.attemptId);
assert.equal(completed.status, 'connected');
const status = controller.status();
assert.equal(status.bots[0].connected, true);
assert.equal(values.get(configs[0].secretRef), 'private-secret');
assert.equal(runtimeArgs.secret, 'private-secret');
assert.doesNotMatch(JSON.stringify(status), /private-secret|secretRef|remote-bot|host-only-code/);
});
test('Enterprise WeChat RPC encodes the QR on Host and strips every authorization field', async () => {
const handler = createWecomRpcHandler({
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
startProvisioning: async () => ({
attemptId: 'attempt_1',
status: 'pending',
verificationUrl: 'https://work.weixin.qq.com/ai/qc/auth?ticket=opaque',
scode: 'never-public',
secret: 'never-public',
remoteBotId: 'never-public',
expiresAt: Date.now() + 1_000,
}),
registrationStatus: async () => null,
cancelProvisioning: async () => ({}),
reconnectBot: async () => ({}),
deleteBot: async () => ({}),
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
const result = await handler(WECOM_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
assert.equal(result.ok, true);
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
assert.doesNotMatch(JSON.stringify(result), /work\.weixin|never-public|remoteBotId|scode|secret/);
});

View file

@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import { WecomRuntime } from '../../../src/channels/wecom/wecom-runtime.mjs';
class FakeClient extends EventEmitter {
disconnected = false;
connect() { queueMicrotask(() => this.emit('authenticated')); }
disconnect() { this.disconnected = true; }
async replyStream() {}
async replyStreamNonBlocking() {}
async sendMessage() {}
}
test('Enterprise WeChat runtime waits for authentication, suppresses SDK payload logs, and reconnects', async () => {
const client = new FakeClient();
let options;
const logs = [];
const runtime = new WecomRuntime({
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
secret: 'private-secret',
harness: { ensureRunning: async () => true },
state: {},
createClient: (value) => { options = value; return client; },
logger: { debug: (...args) => logs.push(args), warn() {} },
connectTimeoutMs: 100,
});
const status = await runtime.start();
assert.equal(status.ready, true);
assert.equal(status.wecomConnectionState, 'connected');
assert.equal(options.botId, 'remote-bot');
assert.equal(options.secret, 'private-secret');
options.logger.debug('raw message payload');
options.logger.warn('raw unknown frame');
assert.deepEqual(logs, []);
client.emit('disconnected', 'network');
assert.equal(runtime.status.ready, false);
assert.equal(runtime.status.wecomConnectionState, 'connecting');
client.emit('authenticated');
assert.equal(runtime.status.ready, true);
await runtime.stop();
assert.equal(client.disconnected, true);
assert.equal(runtime.status.ready, false);
});
test('Enterprise WeChat runtime never reports ready without SDK authentication', async () => {
const client = new FakeClient();
client.connect = () => {};
const runtime = new WecomRuntime({
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
secret: 'private-secret',
harness: { ensureRunning: async () => true },
state: {},
createClient: () => client,
connectTimeoutMs: 5,
});
await assert.rejects(() => runtime.start(), /authentication timed out/);
assert.equal(runtime.status.ready, false);
assert.equal(client.disconnected, true);
});
test('Enterprise WeChat runtime stop cancels an in-flight authentication wait', async () => {
const client = new FakeClient();
client.connect = () => {};
const runtime = new WecomRuntime({
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
secret: 'private-secret',
harness: { ensureRunning: async () => true },
state: {},
createClient: () => client,
connectTimeoutMs: 60_000,
});
const starting = runtime.start();
await new Promise((resolve) => setImmediate(resolve));
await runtime.stop();
await assert.rejects(starting, { name: 'AbortError' });
assert.equal(client.disconnected, true);
assert.equal(runtime.status.wecomConnectionState, 'idle');
});

View file

@ -19,6 +19,10 @@ import {
AccountCard as WeixinAccountCard,
WeixinSettingsTab,
} from '../plugin-src/client/channels/weixin/index.js';
import {
AccountCard as WecomAccountCard,
WecomSettingsTab,
} from '../plugin-src/client/channels/wecom/index.js';
const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url);
const FEISHU_STYLES_URL = new URL(
@ -33,6 +37,10 @@ const DINGTALK_STYLES_URL = new URL(
'../plugin-src/client/channels/dingtalk/styles.js',
import.meta.url,
);
const WECOM_STYLES_URL = new URL(
'../plugin-src/client/channels/wecom/styles.js',
import.meta.url,
);
const FEISHU_SOURCE_URL = new URL(
'../plugin-src/client/channels/feishu/index.js',
import.meta.url,
@ -46,12 +54,17 @@ const DINGTALK_CLIENT_SOURCE_URL = new URL(
'../plugin-src/client/channels/dingtalk/index.js',
import.meta.url,
);
const WECOM_SOURCE_URL = new URL(
'../plugin-src/client/channels/wecom/index.js',
import.meta.url,
);
test('IM settings renders four compact logo channel tabs without enable switches', () => {
test('IM settings renders five compact logo channel tabs without enable switches', () => {
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
feishuRpcCall: async () => ({ ok: true, value: {} }),
weixinRpcCall: async () => ({ ok: true, value: {} }),
dingtalkRpcCall: async () => ({ ok: true, value: {} }),
wecomRpcCall: async () => ({ ok: true, value: {} }),
qqRpcCall: async () => ({ ok: true, value: {} }),
}));
@ -61,12 +74,14 @@ test('IM settings renders four compact logo channel tabs without enable switches
assert.match(markup, />微信</);
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.match(markup, /dim-logoWecom/);
assert.match(markup, /dim-logoQq/);
assert.equal((markup.match(/role="tab"/g) ?? []).length, 4);
assert.equal((markup.match(/role="tab"/g) ?? []).length, 5);
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
@ -138,20 +153,23 @@ test('Feishu keeps its heading controls on one row without a plus icon', async (
});
test('scan actions align left while online totals align right in every channel', async () => {
const [imStyles, feishuStyles, weixinStyles, dingtalkStyles, feishuSource, weixinSource, dingtalkSource] = await Promise.all([
const [imStyles, feishuStyles, weixinStyles, dingtalkStyles, wecomStyles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([
readFile(STYLES_URL, 'utf8'),
readFile(FEISHU_STYLES_URL, 'utf8'),
readFile(WEIXIN_STYLES_URL, 'utf8'),
readFile(DINGTALK_STYLES_URL, 'utf8'),
readFile(WECOM_STYLES_URL, 'utf8'),
readFile(FEISHU_SOURCE_URL, 'utf8'),
readFile(WEIXIN_SOURCE_URL, 'utf8'),
readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'),
readFile(WECOM_SOURCE_URL, 'utf8'),
]);
assert.match(imStyles, /\.dim-panel \.bxf-headingTools, \.dim-panel \.dxw-tools, \.dim-panel \.ddt-tools \{[^}]*justify-content: space-between;/);
assert.match(feishuStyles, /\.bxf-headingTools \{[^}]*justify-content: space-between;/);
assert.match(weixinStyles, /\.dxw-tools \{[^}]*justify-content: space-between;/);
assert.match(dingtalkStyles, /\.ddt-tools \{[^}]*justify-content: space-between;/);
assert.match(wecomStyles, /\.dwecom-page/);
const headingSource = (source) => source.slice(
source.indexOf('function Heading'),
@ -160,11 +178,13 @@ test('scan actions align left while online totals align right in every channel',
const feishuHeading = headingSource(feishuSource);
const weixinHeading = headingSource(weixinSource);
const dingtalkHeading = headingSource(dingtalkSource);
const wecomHeading = headingSource(wecomSource);
assert.ok(feishuHeading.indexOf('扫码绑定机器人') < feishuHeading.indexOf('bxf-totalBadge'));
assert.ok(weixinHeading.indexOf('扫码绑定微信') < weixinHeading.indexOf('dxw-badge'));
assert.ok(dingtalkHeading.indexOf('扫码接入钉钉') < dingtalkHeading.indexOf('ddt-badge'));
assert.ok(wecomHeading.indexOf('扫码绑定企业微信机器人') < wecomHeading.indexOf('ddt-badge'));
for (const heading of [feishuHeading, weixinHeading, dingtalkHeading]) {
for (const heading of [feishuHeading, weixinHeading, dingtalkHeading, wecomHeading]) {
assert.match(heading, /dim-scanButton/);
assert.match(heading, /dim-onlineBadge/);
}
@ -175,7 +195,7 @@ test('scan actions align left while online totals align right in every channel',
});
test('channel headings omit the redundant local credential badge', () => {
const components = [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab];
const components = [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab, WecomSettingsTab];
for (const Component of components) {
const markup = renderToStaticMarkup(React.createElement(Component, {
@ -186,14 +206,15 @@ test('channel headings omit the redundant local credential badge', () => {
});
test('all channel settings states use the DingTalk page treatment', async () => {
const [styles, feishuSource, weixinSource, dingtalkSource] = await Promise.all([
const [styles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([
readFile(STYLES_URL, 'utf8'),
readFile(FEISHU_SOURCE_URL, 'utf8'),
readFile(WEIXIN_SOURCE_URL, 'utf8'),
readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'),
readFile(WECOM_SOURCE_URL, 'utf8'),
]);
for (const Component of [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab]) {
for (const Component of [FeishuSettingsTab, WeixinSettingsTab, DingtalkSettingsTab, WecomSettingsTab]) {
const markup = renderToStaticMarkup(React.createElement(Component, {
rpcCall: async () => ({ ok: true, value: {} }),
}));
@ -202,7 +223,7 @@ test('all channel settings states use the DingTalk page treatment', async () =>
assert.match(markup, /dim-spinner/);
}
for (const source of [feishuSource, weixinSource, dingtalkSource]) {
for (const source of [feishuSource, weixinSource, dingtalkSource, wecomSource]) {
for (const className of [
'dim-channelPage',
'dim-surfaceCard',
@ -232,6 +253,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
feishuRpcCall: async () => ({ ok: true, value: {} }),
weixinRpcCall: async () => ({ ok: true, value: {} }),
dingtalkRpcCall: async () => ({ ok: true, value: {} }),
wecomRpcCall: async () => ({ ok: true, value: {} }),
}));
const accountMarkup = renderToStaticMarkup(React.createElement(WeixinAccountCard, {
account: {
@ -250,6 +272,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
assert.match(railMarkup, /data-im-channel-logo="weixin"/);
assert.match(railMarkup, /data-im-channel-logo="feishu"/);
assert.match(railMarkup, /data-im-channel-logo="wecom"/);
assert.match(accountMarkup, /class="dxw-card dim-botCard"/);
assert.match(accountMarkup, /class="dxw-avatar dim-botAvatar"[^]*data-im-channel-logo="weixin"/);
assert.match(accountMarkup, /class="dxw-health dim-botHealth"/);
@ -259,6 +282,20 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
assert.doesNotMatch(accountMarkup, /收到 \/ 回复/);
});
test('Enterprise WeChat cards reuse the rail logo and compact action treatment', () => {
const markup = renderToStaticMarkup(React.createElement(WecomAccountCard, {
account: {
botId: 'wecom-card', state: 'connected', connected: true,
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
health: { summary: '企业微信 WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
},
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
}));
assert.match(markup, /data-im-channel-logo="wecom"/);
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
});
test('DingTalk bot cards omit the redundant received and replied metric', () => {
const markup = renderToStaticMarkup(React.createElement(DingtalkAccountCard, {
account: {

View file

@ -3,12 +3,13 @@ import test from 'node:test';
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context', async () => {
test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside one plugin context', async () => {
const calls = [];
const plugin = createImHostPlugin({
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
applyWeixin: async (ctx, config) => calls.push(['weixin', ctx, config]),
applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]),
applyWecom: async (ctx, config) => calls.push(['wecom', ctx, config]),
applyQq: async (ctx, config) => calls.push(['qq', ctx, config]),
});
const ctx = { marker: 'shared-context' };
@ -16,6 +17,7 @@ test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context',
feishu: { domain: 'feishu' },
weixin: { timeout: 30 },
dingtalk: { replyTimeoutMs: 60_000 },
wecom: { replyTimeoutMs: 60_000 },
qq: { replyTimeoutMs: 60_000 },
};
@ -27,6 +29,7 @@ test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context',
['feishu', ctx, config.feishu],
['weixin', ctx, config.weixin],
['dingtalk', ctx, config.dingtalk],
['wecom', ctx, config.wecom],
['qq', ctx, config.qq],
]);
});
@ -37,6 +40,7 @@ test('Host does not start Weixin when Feishu activation fails', async () => {
applyFeishu: async () => { throw new Error('feishu unavailable'); },
applyWeixin: async () => { weixinStarted = true; },
applyDingtalk: async () => { throw new Error('DingTalk must not start'); },
applyWecom: async () => { throw new Error('Enterprise WeChat must not start'); },
applyQq: async () => { throw new Error('QQ must not start'); },
});
@ -50,6 +54,7 @@ test('Host does not start DingTalk when Weixin activation fails', async () => {
applyFeishu: async () => {},
applyWeixin: async () => { throw new Error('weixin unavailable'); },
applyDingtalk: async () => { dingtalkStarted = true; },
applyWecom: async () => { throw new Error('Enterprise WeChat must not start'); },
applyQq: async () => { throw new Error('QQ must not start'); },
});
@ -57,15 +62,30 @@ test('Host does not start DingTalk when Weixin activation fails', async () => {
assert.equal(dingtalkStarted, false);
});
test('Host does not start QQ when DingTalk activation fails', async () => {
let qqStarted = false;
test('Host does not start Enterprise WeChat when DingTalk activation fails', async () => {
let wecomStarted = false;
const plugin = createImHostPlugin({
applyFeishu: async () => {},
applyWeixin: async () => {},
applyDingtalk: async () => { throw new Error('dingtalk unavailable'); },
applyQq: async () => { qqStarted = true; },
applyWecom: async () => { wecomStarted = true; },
applyQq: async () => { throw new Error('QQ must not start'); },
});
await assert.rejects(() => plugin.apply({}, {}), /dingtalk unavailable/);
assert.equal(wecomStarted, false);
});
test('Host does not start QQ when Enterprise WeChat activation fails', async () => {
let qqStarted = false;
const plugin = createImHostPlugin({
applyFeishu: async () => {},
applyWeixin: async () => {},
applyDingtalk: async () => {},
applyWecom: async () => { throw new Error('wecom unavailable'); },
applyQq: async () => { qqStarted = true; },
});
await assert.rejects(() => plugin.apply({}, {}), /wecom unavailable/);
assert.equal(qqStarted, false);
});