mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 22:00:50 +08:00
Add unified IM channel plugin
This commit is contained in:
commit
b8bd681a79
26 changed files with 7901 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.tgz
|
||||
.DS_Store
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 xmanrui
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
37
README.md
Normal file
37
README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# dsh-im
|
||||
|
||||
通过扫码把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书和微信机器人。
|
||||
|
||||
> GitHub 简介:通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉等)。
|
||||
|
||||
## 当前内置渠道
|
||||
|
||||
- 飞书:扫码创建并绑定机器人,使用长连接收发消息;
|
||||
- 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息。
|
||||
|
||||
钉钉等其他 IM 平台可继续按同一渠道适配器结构接入;当前可操作界面仅展示已实现的飞书和微信。
|
||||
|
||||
## 安装
|
||||
|
||||
```sh
|
||||
npx -y github:xmanrui/dsh-im install
|
||||
```
|
||||
|
||||
重启 `dsh web`,然后打开「设置 → 插件 → IM机器人」。安装器会用 `dsh-im` 替换 profile 中直接安装的 `dsh-feishu` 和 `dsh-weixin`,但不删除任何渠道数据;原有飞书凭据和微信扫码绑定会继续使用。
|
||||
|
||||
## 设计
|
||||
|
||||
- Harness 中只注册一个「IM机器人」设置页;
|
||||
- 左侧使用飞书和微信 Logo 切换渠道,不使用启用/停用开关;
|
||||
- 两个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
||||
- 浏览器只获得二维码和脱敏状态,不获得 App Secret 或 `bot_token`。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run check
|
||||
node bin/dsh-im.mjs install --source .
|
||||
```
|
||||
|
||||
`npm run check` 运行单元测试、构建 Host/Client 产物,并验证发布包不包含凭据或独立飞书/微信设置页注册。
|
||||
7
THIRD_PARTY_NOTICES.md
Normal file
7
THIRD_PARTY_NOTICES.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Third-party notices
|
||||
|
||||
This package composes [`@xmanrui/dsh-feishu`](https://github.com/xmanrui/dsh-feishu) at commit `aad650feabadd511241aa58b236d64273d5e397f` and [`@xmanrui/dsh-weixin`](https://github.com/xmanrui/dsh-weixin) at commit `76d076771b2c84fb4c5598c2344d486695eda080`, both under the MIT License.
|
||||
|
||||
The channel client components are adapted from those projects and preserve their security model: secrets remain in the DeepSeek Harness Host credential provider and never enter the browser-facing RPC response.
|
||||
|
||||
The WeChat mark uses path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu mark is an original inline vector used for channel identification. Product names and logos remain trademarks of their respective owners.
|
||||
95
bin/dsh-im.mjs
Executable file
95
bin/dsh-im.mjs
Executable file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const PACKAGE_NAME = '@xmanrui/dsh-im';
|
||||
const DEFAULT_SOURCE = 'github:xmanrui/dsh-im';
|
||||
const LEGACY_PACKAGES = ['@xmanrui/dsh-feishu', '@xmanrui/dsh-weixin'];
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
dsh-im install [--profile web] [--source <package-spec>]
|
||||
dsh-im uninstall [--profile web]
|
||||
|
||||
Examples:
|
||||
npx -y github:xmanrui/dsh-im install
|
||||
dsh-im install --source .`);
|
||||
}
|
||||
|
||||
function takeOption(args, name, fallback) {
|
||||
const index = args.indexOf(name);
|
||||
if (index < 0) return fallback;
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`);
|
||||
args.splice(index, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
function runDsh(args) {
|
||||
const result = spawnSync('dsh', args, {
|
||||
cwd: tmpdir(),
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
if (result.error?.code === 'ENOENT') {
|
||||
throw new Error('找不到 dsh,请先安装 DeepSeek Harness 并确保 dsh 在 PATH 中。');
|
||||
}
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`dsh 退出,状态码 ${result.status ?? 1}`);
|
||||
}
|
||||
|
||||
async function directProfilePackages(profile) {
|
||||
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
|
||||
const profilePackage = join(dshHome, 'profiles', profile, 'package.json');
|
||||
try {
|
||||
const manifest = JSON.parse(await readFile(profilePackage, 'utf8'));
|
||||
const bundles = new Set(manifest.dsh?.profile?.bundles ?? []);
|
||||
const dependencies = manifest.dependencies ?? {};
|
||||
return new Set(LEGACY_PACKAGES.filter((name) => dependencies[name] || bundles.has(name)));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return new Set();
|
||||
throw new Error(`无法读取 Harness 配置 ${profilePackage}:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args.shift();
|
||||
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = takeOption(args, '--profile', 'web');
|
||||
if (command === 'install') {
|
||||
const requested = takeOption(args, '--source', DEFAULT_SOURCE);
|
||||
const source = requested === '.' || requested === '..'
|
||||
|| requested.startsWith('./') || requested.startsWith('../')
|
||||
? resolve(process.cwd(), requested)
|
||||
: (isAbsolute(requested) ? requested : requested);
|
||||
if (args.length > 0) throw new Error(`无法识别的参数:${args.join(' ')}`);
|
||||
|
||||
const legacy = await directProfilePackages(profile);
|
||||
runDsh(['plugin', '--profile', profile, 'add', '--save-exact', source]);
|
||||
for (const packageName of legacy) {
|
||||
runDsh(['plugin', '--profile', profile, 'remove', packageName]);
|
||||
}
|
||||
console.log('\nIM 机器人插件已安装。请重启 dsh web,然后打开「设置 → 插件 → IM机器人」。');
|
||||
if (legacy.size > 0) {
|
||||
console.log('已用 dsh-im 替换独立飞书/微信插件;原有凭据和扫码绑定保持不变。');
|
||||
}
|
||||
} else if (command === 'uninstall') {
|
||||
if (args.length > 0) throw new Error(`无法识别的参数:${args.join(' ')}`);
|
||||
runDsh(['plugin', '--profile', profile, 'remove', PACKAGE_NAME]);
|
||||
console.log('\nIM 机器人插件已卸载。请重启 dsh web。');
|
||||
} else {
|
||||
throw new Error(`无法识别的命令:${command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`dsh-im: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
3
cordis.patch.yml
Normal file
3
cordis.patch.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
- insert:
|
||||
- id: xmanrui-dsh-im
|
||||
name: '@xmanrui/dsh-im'
|
||||
2877
lib/client.js
Normal file
2877
lib/client.js
Normal file
File diff suppressed because it is too large
Load diff
26
lib/index.js
Normal file
26
lib/index.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// plugin-src/host/index.mjs
|
||||
import { apply as applyFeishu } from "@xmanrui/dsh-feishu";
|
||||
import { apply as applyWeixin } from "@xmanrui/dsh-weixin";
|
||||
var name = "dsh-im-host";
|
||||
var inject = ["connection", "credentials", "webServer"];
|
||||
function createImHostPlugin(internals = {}) {
|
||||
const startFeishu = internals.applyFeishu ?? applyFeishu;
|
||||
const startWeixin = internals.applyWeixin ?? applyWeixin;
|
||||
return Object.freeze({
|
||||
name,
|
||||
inject,
|
||||
async apply(ctx, config = {}) {
|
||||
await startFeishu(ctx, config.feishu ?? {});
|
||||
await startWeixin(ctx, config.weixin ?? {});
|
||||
}
|
||||
});
|
||||
}
|
||||
async function apply(ctx, config = {}) {
|
||||
return createImHostPlugin().apply(ctx, config);
|
||||
}
|
||||
export {
|
||||
apply,
|
||||
createImHostPlugin,
|
||||
inject,
|
||||
name
|
||||
};
|
||||
1521
package-lock.json
generated
Normal file
1521
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
68
package.json
Normal file
68
package.json
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"name": "@xmanrui/dsh-im",
|
||||
"version": "0.1.0",
|
||||
"description": "通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉等)。",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/xmanrui/dsh-im.git"
|
||||
},
|
||||
"homepage": "https://github.com/xmanrui/dsh-im#readme",
|
||||
"bugs": "https://github.com/xmanrui/dsh-im/issues",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bin": {
|
||||
"dsh-im": "./bin/dsh-im.mjs"
|
||||
},
|
||||
"main": "./lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./client": "./lib/client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"cordis.patch.yml",
|
||||
"lib",
|
||||
"plugin-src",
|
||||
"scripts",
|
||||
"src",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"THIRD_PARTY_NOTICES.md"
|
||||
],
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node plugin-src/client/build.mjs && node plugin-src/host/build.mjs",
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"check": "npm test && npm run build && node scripts/verify-package.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmanrui/dsh-feishu": "https://github.com/xmanrui/dsh-feishu/archive/aad650feabadd511241aa58b236d64273d5e397f.tar.gz",
|
||||
"@xmanrui/dsh-weixin": "https://github.com/xmanrui/dsh-weixin/archive/76d076771b2c84fb4c5598c2344d486695eda080.tar.gz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.25.9",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
}
|
||||
}
|
||||
38
plugin-src/client/build.mjs
Normal file
38
plugin-src/client/build.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { build } from 'esbuild';
|
||||
|
||||
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = resolve(sourceDirectory, '../..');
|
||||
const outputPath = resolve(packageRoot, 'lib/client.js');
|
||||
const loaderId = process.env.DSH_IM_CLIENT_ID ?? '@xmanrui/dsh-im';
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [resolve(sourceDirectory, 'index.js')],
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
platform: 'browser',
|
||||
target: ['chrome100'],
|
||||
external: ['react'],
|
||||
write: false,
|
||||
minify: process.env.NODE_ENV === 'production',
|
||||
legalComments: 'none',
|
||||
});
|
||||
const bundled = result.outputFiles?.[0]?.text;
|
||||
if (!bundled) throw new Error('esbuild did not produce a client bundle');
|
||||
|
||||
const wrapped = `window.__ModuleLoader__.load({
|
||||
id: ${JSON.stringify(loaderId)},
|
||||
factory: (require) => {
|
||||
var module = { exports: {} };
|
||||
var exports = module.exports;
|
||||
${bundled}
|
||||
return module.exports;
|
||||
}
|
||||
});
|
||||
`;
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, wrapped, 'utf8');
|
||||
console.log(`Wrote ${outputPath}`);
|
||||
324
plugin-src/client/channels/feishu/api.js
Normal file
324
plugin-src/client/channels/feishu/api.js
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* Browser-safe contract for the Feishu Host plugin.
|
||||
*
|
||||
* The Host owns app registration and credentials. This module deliberately
|
||||
* models only redacted presentation data; app secrets and credential refs
|
||||
* must never be returned by any endpoint on this channel.
|
||||
*/
|
||||
|
||||
export const FEISHU_RPC_CHANNEL = "/feishu";
|
||||
|
||||
export const FEISHU_ENDPOINTS = Object.freeze({
|
||||
status: "connection.status",
|
||||
beginProvisioning: "provision.begin",
|
||||
pollProvisioning: "provision.poll",
|
||||
cancelProvisioning: "provision.cancel",
|
||||
reconnectBot: "bot.reconnect",
|
||||
disconnectBot: "bot.disconnect",
|
||||
deleteBot: "bot.delete",
|
||||
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
|
||||
testConnection: "connection.test",
|
||||
disconnect: "connection.disconnect",
|
||||
});
|
||||
|
||||
const CONNECTION_STATES = new Set([
|
||||
"disconnected",
|
||||
"offline",
|
||||
"provisioning",
|
||||
"connecting",
|
||||
"reconnecting",
|
||||
"connected",
|
||||
"error",
|
||||
]);
|
||||
|
||||
const POLL_STATES = new Set([
|
||||
"pending",
|
||||
"scanned",
|
||||
"connecting",
|
||||
"connected",
|
||||
"expired",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function optionalTimestamp(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clamp(value, min, max, fallback) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.min(max, Math.max(min, value))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function unwrapRpcResult(result) {
|
||||
if (!isRecord(result) || typeof result.ok !== "boolean") {
|
||||
throw new Error("飞书服务返回了无法识别的响应");
|
||||
}
|
||||
if (!result.ok) {
|
||||
const message = optionalString(result.error?.message) ?? "飞书服务请求失败";
|
||||
const error = new Error(message);
|
||||
error.code = optionalString(result.error?.code) ?? "FEISHU_RPC_ERROR";
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export function normalizeProvisioning(value, now = Date.now()) {
|
||||
const source = isRecord(value?.provisioning) ? value.provisioning : value;
|
||||
if (!isRecord(source)) throw new Error("飞书服务没有返回二维码信息");
|
||||
|
||||
const attemptId = optionalString(source.attemptId)
|
||||
?? optionalString(source.provisioningId);
|
||||
const verificationUrl = optionalString(source.verificationUrl);
|
||||
const qrCodeDataUrl = optionalString(source.qrCodeDataUrl);
|
||||
if (!attemptId || (!verificationUrl && !qrCodeDataUrl)) {
|
||||
throw new Error("飞书服务返回的二维码信息不完整");
|
||||
}
|
||||
|
||||
const explicitExpiry = optionalTimestamp(source.expiresAt);
|
||||
const expireIn = clamp(source.expireIn, 1, 60 * 60, 5 * 60);
|
||||
return {
|
||||
attemptId,
|
||||
verificationUrl,
|
||||
qrCodeDataUrl,
|
||||
expiresAt: explicitExpiry ?? now + expireIn * 1000,
|
||||
pollIntervalMs: clamp(source.pollIntervalMs, 800, 10_000, 1_800),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBot(value) {
|
||||
const source = isRecord(value) ? value : {};
|
||||
return {
|
||||
name: optionalString(source.name) ?? "飞书机器人",
|
||||
avatarUrl: optionalString(source.avatarUrl),
|
||||
appIdMasked: optionalString(source.appIdMasked),
|
||||
tenantName: optionalString(source.tenantName),
|
||||
domain: source.domain === "lark" ? "lark" : "feishu",
|
||||
activated: typeof source.activated === "boolean" || typeof source.activated === "number"
|
||||
? source.activated
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHealth(value, connected = false) {
|
||||
const source = isRecord(value) ? value : {};
|
||||
const fallbackStatus = connected ? "healthy" : "offline";
|
||||
const status = ["healthy", "degraded", "offline", "checking"].includes(source.status)
|
||||
? source.status
|
||||
: fallbackStatus;
|
||||
return {
|
||||
status,
|
||||
summary: optionalString(source.summary)
|
||||
?? (connected ? "长连接运行正常" : "机器人尚未连接"),
|
||||
lastCheckedAt: optionalTimestamp(source.lastCheckedAt),
|
||||
lastConnectedAt: optionalTimestamp(source.lastConnectedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeError(value) {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const message = optionalString(value.message);
|
||||
if (!message) return undefined;
|
||||
return { message, code: optionalString(value.code) };
|
||||
}
|
||||
|
||||
function authoritativeState(value, connected) {
|
||||
if (connected) return "connected";
|
||||
const reported = CONNECTION_STATES.has(value) ? value : "disconnected";
|
||||
if (reported === "connected" || reported === "connecting" || reported === "reconnecting") {
|
||||
return "connecting";
|
||||
}
|
||||
if (reported === "error") return "error";
|
||||
return "offline";
|
||||
}
|
||||
|
||||
/** Normalize one redacted bot connection. `connected` is authoritative. */
|
||||
export function normalizeBotConnection(value, fallbackBotId) {
|
||||
if (!isRecord(value)) throw new Error("飞书服务返回了无效的机器人状态");
|
||||
const botId = optionalString(value.botId) ?? optionalString(fallbackBotId);
|
||||
if (!botId) throw new Error("飞书服务返回的机器人缺少 botId");
|
||||
const connected = value.connected === true;
|
||||
return {
|
||||
botId,
|
||||
state: authoritativeState(value.state, connected),
|
||||
connected,
|
||||
configured: value.configured !== false,
|
||||
bot: normalizeBot(value.bot),
|
||||
health: normalizeHealth(value.health, connected),
|
||||
error: normalizeError(value.error),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the v2 multi-bot list. A singleton fallback is accepted only so a
|
||||
* browser/Host rolling upgrade does not strand an existing connection.
|
||||
*/
|
||||
export function normalizeBotsSnapshot(value) {
|
||||
if (!isRecord(value)) throw new Error("飞书服务没有返回连接状态");
|
||||
|
||||
let sourceBots = Array.isArray(value.bots) ? value.bots : [];
|
||||
if (sourceBots.length === 0 && value.configured === true) {
|
||||
sourceBots = [{
|
||||
botId: optionalString(value.botId) ?? "legacy-default",
|
||||
state: value.state,
|
||||
connected: value.connected,
|
||||
configured: true,
|
||||
bot: value.bot,
|
||||
health: value.health,
|
||||
error: value.error,
|
||||
}];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const bots = [];
|
||||
for (const source of sourceBots) {
|
||||
const bot = normalizeBotConnection(source);
|
||||
if (seen.has(bot.botId)) continue;
|
||||
seen.add(bot.botId);
|
||||
bots.push(bot);
|
||||
}
|
||||
|
||||
const configured = bots.filter((bot) => bot.configured).length;
|
||||
const connected = bots.filter((bot) => bot.connected).length;
|
||||
const revision = Number.isSafeInteger(value.revision) && value.revision >= 0
|
||||
? value.revision
|
||||
: 0;
|
||||
const state = CONNECTION_STATES.has(value.state) ? value.state : "disconnected";
|
||||
|
||||
return {
|
||||
schemaVersion: value.schemaVersion === 2 ? 2 : 1,
|
||||
revision,
|
||||
state,
|
||||
bots,
|
||||
// Derive counts from the authoritative list so stale summary fields never
|
||||
// make the UI claim that an unavailable bot is online.
|
||||
totals: { configured, connected },
|
||||
provisioning: value.provisioning
|
||||
? normalizeProvisioning(value.provisioning)
|
||||
: undefined,
|
||||
error: normalizeError(value.error),
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy single-bot normalizer retained for the compatibility surface. */
|
||||
export function normalizeConnectionSnapshot(value) {
|
||||
if (!isRecord(value)) throw new Error("飞书服务没有返回连接状态");
|
||||
const connected = value.connected === true;
|
||||
const reportedState = CONNECTION_STATES.has(value.state)
|
||||
? value.state
|
||||
: "disconnected";
|
||||
const state = connected
|
||||
? "connected"
|
||||
: reportedState === "connected"
|
||||
? "connecting"
|
||||
: reportedState;
|
||||
const snapshot = {
|
||||
state,
|
||||
configured: value.configured === true,
|
||||
bot: normalizeBot(value.bot),
|
||||
health: normalizeHealth(value.health, connected),
|
||||
provisioning: undefined,
|
||||
errorMessage: optionalString(value.error?.message) ?? optionalString(value.message),
|
||||
};
|
||||
if (value.provisioning) snapshot.provisioning = normalizeProvisioning(value.provisioning);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Legacy UI projection retained for tests and rolling upgrades. */
|
||||
export function screenFromSnapshot(snapshot) {
|
||||
switch (snapshot.state) {
|
||||
case "connected":
|
||||
return { phase: "connected", configured: snapshot.configured, bot: snapshot.bot, health: snapshot.health };
|
||||
case "provisioning":
|
||||
return snapshot.provisioning
|
||||
? { phase: "qr", configured: snapshot.configured, provision: snapshot.provisioning, expired: false }
|
||||
: { phase: "creating", configured: snapshot.configured };
|
||||
case "connecting":
|
||||
return { phase: "connecting", configured: snapshot.configured, provision: snapshot.provisioning };
|
||||
case "error":
|
||||
return {
|
||||
phase: "error",
|
||||
configured: snapshot.configured,
|
||||
bot: snapshot.bot,
|
||||
health: snapshot.health,
|
||||
error: { message: snapshot.errorMessage ?? "飞书连接遇到问题", code: "FEISHU_CONNECTION_ERROR" },
|
||||
retry: snapshot.configured ? "test" : "begin",
|
||||
};
|
||||
default:
|
||||
return snapshot.configured
|
||||
? { phase: "offline", configured: true, bot: snapshot.bot, health: snapshot.health }
|
||||
: { phase: "disconnected", configured: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy helper. The new UI uses reconnectBot with an explicit botId. */
|
||||
export async function retryConnection(invoke, signal) {
|
||||
if (typeof invoke !== "function") throw new TypeError("retryConnection requires an RPC caller");
|
||||
await invoke(FEISHU_ENDPOINTS.testConnection, {}, signal);
|
||||
return invoke(FEISHU_ENDPOINTS.status, {}, signal);
|
||||
}
|
||||
|
||||
/** Reconnect exactly one bot, then fetch the authoritative list once. */
|
||||
export async function reconnectBot(invoke, botId, signal) {
|
||||
if (typeof invoke !== "function") throw new TypeError("reconnectBot requires an RPC caller");
|
||||
const id = optionalString(botId);
|
||||
if (!id) throw new TypeError("reconnectBot requires a botId");
|
||||
await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId: id }, signal);
|
||||
return invoke(FEISHU_ENDPOINTS.status, {}, signal);
|
||||
}
|
||||
|
||||
export function normalizePollResult(value) {
|
||||
if (!isRecord(value)) throw new Error("飞书服务没有返回创建进度");
|
||||
const status = POLL_STATES.has(value.status)
|
||||
? value.status
|
||||
: POLL_STATES.has(value.state)
|
||||
? value.state
|
||||
: undefined;
|
||||
if (!status) throw new Error("飞书服务返回了未知的创建状态");
|
||||
|
||||
const normalized = {
|
||||
status,
|
||||
botId: optionalString(value.botId),
|
||||
message: optionalString(value.error?.message) ?? optionalString(value.message),
|
||||
connection: undefined,
|
||||
provisioning: undefined,
|
||||
};
|
||||
if (value.provisioning) normalized.provisioning = normalizeProvisioning(value.provisioning);
|
||||
if (status === "connected" && isRecord(value.connection)) {
|
||||
normalized.connection = value.connection.botId
|
||||
? normalizeBotConnection(value.connection)
|
||||
: normalizeConnectionSnapshot(value.connection);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Keep transport and Host details out of the user-facing alert. */
|
||||
export function presentError(error) {
|
||||
const raw = optionalString(error?.message) ?? "操作失败,请稍后重试";
|
||||
const message = raw
|
||||
.replace(/(client[_-]?secret|app[_-]?secret|secret|token)\s*[:=]\s*[^\s,;]+/gi, "$1=••••••")
|
||||
.slice(0, 240);
|
||||
return { message, code: optionalString(error?.code) };
|
||||
}
|
||||
|
||||
export function formatRemaining(milliseconds) {
|
||||
const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
936
plugin-src/client/channels/feishu/index.js
Normal file
936
plugin-src/client/channels/feishu/index.js
Normal file
|
|
@ -0,0 +1,936 @@
|
|||
import * as React from "react";
|
||||
|
||||
import {
|
||||
FEISHU_ENDPOINTS,
|
||||
FEISHU_RPC_CHANNEL,
|
||||
formatRemaining,
|
||||
normalizeBotsSnapshot,
|
||||
normalizePollResult,
|
||||
normalizeProvisioning,
|
||||
presentError,
|
||||
unwrapRpcResult,
|
||||
} from "./api.js";
|
||||
import { installFeishuStyles } from "./styles.js";
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
export const name = "feishu-settings";
|
||||
export const inject = ["slots", "connection"];
|
||||
|
||||
function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) {
|
||||
return h("svg", {
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox,
|
||||
fill: "none",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
"aria-hidden": "true",
|
||||
focusable: "false",
|
||||
className,
|
||||
}, children);
|
||||
}
|
||||
|
||||
function ShieldIcon({ size = 18 }) {
|
||||
return h(SvgIcon, { size },
|
||||
h("path", {
|
||||
d: "M12 3 5.5 5.8v5.1c0 4.25 2.72 7.87 6.5 9.1 3.78-1.23 6.5-4.85 6.5-9.1V5.8L12 3Z",
|
||||
stroke: "currentColor", strokeWidth: "1.7", strokeLinejoin: "round",
|
||||
}),
|
||||
h("path", {
|
||||
d: "m9.3 11.8 1.7 1.7 3.8-4", stroke: "currentColor",
|
||||
strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function RobotIcon({ size = 26 }) {
|
||||
return h(SvgIcon, { size },
|
||||
h("rect", {
|
||||
x: "5", y: "7.5", width: "14", height: "11", rx: "4",
|
||||
stroke: "currentColor", strokeWidth: "1.7",
|
||||
}),
|
||||
h("path", {
|
||||
d: "M12 4.5v3M8.7 12h.01M15.3 12h.01M9.2 15.3c1.67 1.08 3.93 1.08 5.6 0M3.5 11.5v3M20.5 11.5v3",
|
||||
stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function SparkIcon({ size = 18 }) {
|
||||
return h(SvgIcon, { size },
|
||||
h("path", {
|
||||
d: "M12 2.8c.75 3.67 2.7 5.62 6.4 6.4-3.7.77-5.65 2.72-6.4 6.4-.75-3.68-2.7-5.63-6.4-6.4 3.7-.78 5.65-2.73 6.4-6.4Z",
|
||||
stroke: "currentColor", strokeWidth: "1.55", strokeLinejoin: "round",
|
||||
}),
|
||||
h("path", {
|
||||
d: "M5.2 15.8c.35 1.7 1.28 2.63 3 3-1.72.36-2.65 1.29-3 3-.36-1.71-1.29-2.64-3-3 1.71-.37 2.64-1.3 3-3ZM18.7 2.7c.22 1.06.79 1.63 1.85 1.85-1.06.22-1.63.79-1.85 1.85-.22-1.06-.79-1.63-1.85-1.85 1.06-.22 1.63-.79 1.85-1.85Z",
|
||||
fill: "currentColor",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon({ size = 17 }) {
|
||||
return h(SvgIcon, { size }, h("path", {
|
||||
d: "M12 5v14M5 12h14", stroke: "currentColor", strokeWidth: "1.8",
|
||||
strokeLinecap: "round",
|
||||
}));
|
||||
}
|
||||
|
||||
function RefreshIcon({ size = 16 }) {
|
||||
return h(SvgIcon, { size }, h("path", {
|
||||
d: "M19 7.5V4m0 0h-3.5M19 4l-2.1 2.1A7 7 0 1 0 19 13",
|
||||
stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
}));
|
||||
}
|
||||
|
||||
function ExternalIcon({ size = 15 }) {
|
||||
return h(SvgIcon, { size }, h("path", {
|
||||
d: "M13 5h6v6M19 5l-8.5 8.5M18 13.5V18a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4.5",
|
||||
stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
}));
|
||||
}
|
||||
|
||||
function AlertIcon({ size = 22 }) {
|
||||
return h(SvgIcon, { size },
|
||||
h("path", {
|
||||
d: "M12 3.4 21 19H3L12 3.4Z", stroke: "currentColor",
|
||||
strokeWidth: "1.7", strokeLinejoin: "round",
|
||||
}),
|
||||
h("path", {
|
||||
d: "M12 9v4.4M12 16.6v.01", stroke: "currentColor",
|
||||
strokeWidth: "1.9", strokeLinecap: "round",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function QrIcon({ size = 58 }) {
|
||||
return h(SvgIcon, { size }, h("path", {
|
||||
d: "M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h2v2h-2v-2Zm4 0h2v4h-2v-4Zm-4 4h4v2h-4v-2Z",
|
||||
fill: "currentColor",
|
||||
}));
|
||||
}
|
||||
|
||||
const Button = React.forwardRef(function Button(
|
||||
{ children, kind = "secondary", size, icon, className = "", ...props },
|
||||
ref,
|
||||
) {
|
||||
return h("button", {
|
||||
...props,
|
||||
ref,
|
||||
type: "button",
|
||||
className: `bxf-button ${className}`.trim(),
|
||||
"data-kind": kind,
|
||||
"data-size": size,
|
||||
}, icon, h("span", null, children));
|
||||
});
|
||||
|
||||
function BrandMark({ compact = false }) {
|
||||
return h("div", { className: compact ? "bxf-avatar" : "bxf-brandMark" },
|
||||
h(RobotIcon, { size: compact ? 25 : 34 }));
|
||||
}
|
||||
|
||||
function Heading({ totals, onAdd, adding, busy, addButtonRef }) {
|
||||
const hasBots = totals.configured > 0;
|
||||
return h("div", { className: "bxf-heading" },
|
||||
h("div", { className: "bxf-headingTools" },
|
||||
hasBots
|
||||
? h("div", {
|
||||
className: "bxf-totalBadge",
|
||||
"aria-label": `已接入 ${totals.configured} 个机器人,其中 ${totals.connected} 个在线`,
|
||||
}, h("strong", null, totals.connected), h("span", null, `/ ${totals.configured} 在线`))
|
||||
: null,
|
||||
h("div", {
|
||||
className: "bxf-localBadge",
|
||||
title: "每个应用的凭据均由 Host 独立保存,不会发送到浏览器",
|
||||
}, h(ShieldIcon, { size: 14 }), h("span", null, "凭据仅保存在本机")),
|
||||
h(Button, {
|
||||
kind: "primary",
|
||||
size: "small",
|
||||
icon: h(PlusIcon),
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
"aria-busy": busy ? "true" : undefined,
|
||||
}, adding ? "正在添加" : "添加机器人"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingView() {
|
||||
return h("div", {
|
||||
className: "bxf-card", "aria-busy": "true", "aria-label": "正在读取飞书机器人列表",
|
||||
}, h("div", { className: "bxf-skeleton" },
|
||||
h("div", { className: "bxf-skeletonLine" }),
|
||||
h("div", { className: "bxf-skeletonLine" }),
|
||||
h("div", { className: "bxf-skeletonLine" }),
|
||||
h("div", { className: "bxf-skeletonLine" }),
|
||||
h("div", { className: "bxf-skeletonBox" }),
|
||||
));
|
||||
}
|
||||
|
||||
function EmptyView({ onStart, busy }) {
|
||||
return h("div", { className: "bxf-card" },
|
||||
h("div", { className: "bxf-cardBody bxf-intro" },
|
||||
h("div", { className: "bxf-introCopy" },
|
||||
h("div", { className: "bxf-stateLabel" },
|
||||
h("span", { className: "bxf-dot" }), h("span", null, "尚未接入机器人")),
|
||||
h("h3", null, "扫码,创建第一个飞书入口"),
|
||||
h("p", null, "无需手动填写 App ID。以后还可以继续添加机器人,分别服务不同团队或飞书租户。"),
|
||||
h("div", { className: "bxf-actions" },
|
||||
h(Button, {
|
||||
kind: "primary", icon: h(SparkIcon), onClick: onStart,
|
||||
disabled: busy, "aria-busy": busy ? "true" : undefined,
|
||||
}, busy ? "正在创建…" : "一键创建飞书机器人")),
|
||||
h("div", { className: "bxf-note" }, h(ShieldIcon, { size: 16 }),
|
||||
h("span", null, "每个 App Secret 都只写入 Host 凭据存储,浏览器不会收到 Secret。")),
|
||||
),
|
||||
h("div", { className: "bxf-markStage", "aria-hidden": "true" }, h(BrandMark)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function safeVerificationHref(value) {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" ? url.toString() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function safeQrSource(value) {
|
||||
if (!value) return undefined;
|
||||
return /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
||||
const [imageFailed, setImageFailed] = React.useState(false);
|
||||
const qrSource = safeQrSource(provision.qrCodeDataUrl);
|
||||
const href = safeVerificationHref(provision.verificationUrl);
|
||||
const remaining = Math.max(0, provision.expiresAt - now);
|
||||
const expired = provision.expired === true || remaining === 0;
|
||||
const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining));
|
||||
|
||||
React.useEffect(() => setImageFailed(false), [qrSource]);
|
||||
|
||||
return h("div", { className: "bxf-card bxf-provisionCard" },
|
||||
h("div", { className: "bxf-cardBody bxf-qrLayout" },
|
||||
h("div", { className: "bxf-qrColumn" },
|
||||
h("div", { className: "bxf-qrFrame" },
|
||||
qrSource && !imageFailed
|
||||
? h("img", {
|
||||
src: qrSource,
|
||||
alt: "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
|
||||
onError: () => setImageFailed(true),
|
||||
})
|
||||
: h("div", { className: "bxf-qrFallback" },
|
||||
h("div", null, h(QrIcon), h("span", null, "二维码未就绪,请打开授权链接"))),
|
||||
expired
|
||||
? h("div", { className: "bxf-expiredOverlay", role: "status" },
|
||||
h("div", null, "二维码已失效", h("br"), "请刷新后重新扫码"))
|
||||
: null,
|
||||
),
|
||||
h("div", {
|
||||
className: "bxf-countdown",
|
||||
"aria-label": expired ? "二维码已失效" : `二维码剩余 ${formatRemaining(remaining)}`,
|
||||
},
|
||||
h("div", { className: "bxf-countdownTop", "aria-hidden": "true" },
|
||||
h("span", null, expired ? "等待刷新" : "二维码有效时间"),
|
||||
h("strong", null, formatRemaining(remaining))),
|
||||
h("div", { className: "bxf-progress", "aria-hidden": "true" },
|
||||
h("span", { style: { "--bxf-progress": `${Math.round(progress * 100)}%` } })),
|
||||
),
|
||||
),
|
||||
h("div", { className: "bxf-qrCopy" },
|
||||
h("div", { className: "bxf-stateLabel" },
|
||||
h("span", { className: "bxf-dot", "data-tone": "warning" }),
|
||||
h("span", null, "正在添加新机器人")),
|
||||
h("h3", null, expired ? "刷新二维码后继续" : "使用飞书扫码创建机器人"),
|
||||
h("p", null, "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
|
||||
h("ol", { className: "bxf-steps" },
|
||||
h("li", null, "打开飞书移动端,使用扫一扫读取二维码"),
|
||||
h("li", null, "核对应用名称与权限范围,并确认创建"),
|
||||
h("li", null, "保持本页打开,等待新机器人的长连接就绪")),
|
||||
h("div", { className: "bxf-actions" },
|
||||
expired
|
||||
? h(Button, {
|
||||
kind: "primary", icon: h(RefreshIcon), onClick: onRefresh, disabled: busy,
|
||||
}, busy ? "刷新中…" : "刷新二维码")
|
||||
: href
|
||||
? h("a", {
|
||||
className: "bxf-button bxf-link", "data-kind": "secondary",
|
||||
href, target: "_blank", rel: "noopener noreferrer",
|
||||
}, h(ExternalIcon), h("span", null, "在飞书中打开"))
|
||||
: null,
|
||||
!expired
|
||||
? h(Button, { icon: h(RefreshIcon), onClick: onRefresh, disabled: busy }, "换一个二维码")
|
||||
: null,
|
||||
h(Button, { onClick: onCancel, disabled: busy }, "取消添加")),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ProvisionProgress({ phase, onCancel, busy }) {
|
||||
const connecting = phase === "connecting";
|
||||
return h("div", { className: "bxf-card bxf-provisionCard", "aria-busy": "true" },
|
||||
h("div", { className: "bxf-connecting bxf-connectingCompact" },
|
||||
h("div", { className: "bxf-connectingCopy" },
|
||||
h("div", { className: "bxf-orbit" },
|
||||
h("div", { className: "bxf-orbitCore" },
|
||||
connecting ? h(RobotIcon, { size: 24 }) : h(SparkIcon, { size: 24 }))),
|
||||
h("h3", null, connecting ? "已确认,正在连接新机器人" : "正在准备授权二维码"),
|
||||
h("p", null, connecting
|
||||
? "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
|
||||
: "正在向飞书申请一次性授权二维码,请稍候。"),
|
||||
connecting
|
||||
? h("div", { className: "bxf-actions", style: { justifyContent: "center" } },
|
||||
h(Button, { onClick: onCancel, disabled: busy }, "取消添加"))
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ProvisionError({ error, onRetry, onCancel, busy }) {
|
||||
return h("div", { className: "bxf-card bxf-provisionCard" },
|
||||
h("div", { className: "bxf-inlineError", role: "alert" },
|
||||
h("div", { className: "bxf-errorIcon" }, h(AlertIcon)),
|
||||
h("div", null,
|
||||
h("h3", null, "新机器人没有添加完成"),
|
||||
h("p", null, error.message),
|
||||
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
|
||||
h("div", { className: "bxf-actions" },
|
||||
h(Button, { kind: "primary", icon: h(RefreshIcon), onClick: onRetry, disabled: busy },
|
||||
busy ? "重试中…" : "重新生成二维码"),
|
||||
h(Button, { onClick: onCancel, disabled: busy }, "关闭")),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const HEALTH_LABELS = {
|
||||
connected: "运行正常",
|
||||
connecting: "正在连接",
|
||||
offline: "连接中断",
|
||||
error: "需要处理",
|
||||
};
|
||||
|
||||
function formatCheckedTime(timestamp) {
|
||||
if (!timestamp) return "尚未检查";
|
||||
try {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
}).format(new Date(timestamp));
|
||||
} catch {
|
||||
return "刚刚";
|
||||
}
|
||||
}
|
||||
|
||||
function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
|
||||
const cancelRef = React.useRef(null);
|
||||
const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
const titleId = `bxf-remove-title-${idPart}`;
|
||||
const descriptionId = `bxf-remove-description-${idPart}`;
|
||||
|
||||
React.useEffect(() => cancelRef.current?.focus(), []);
|
||||
|
||||
return h("div", {
|
||||
className: "bxf-confirm",
|
||||
role: "alertdialog",
|
||||
"aria-labelledby": titleId,
|
||||
"aria-describedby": descriptionId,
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === "Escape" && !busy) {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
},
|
||||
h("h4", { id: titleId }, `从 DeepSeek Harness 移除“${bot.bot.name}”?`),
|
||||
h("p", { id: descriptionId },
|
||||
"此操作会停止这个机器人的连接,并删除保存在本机的接入配置和凭据。飞书开放平台中的应用不会被自动删除,其他机器人也不受影响。"),
|
||||
h("div", { className: "bxf-actions" },
|
||||
h(Button, { ref: cancelRef, onClick: onCancel, disabled: busy }, "保留机器人"),
|
||||
h(Button, { kind: "danger", onClick: onConfirm, disabled: busy },
|
||||
busy ? "正在移除…" : "确认移除接入")),
|
||||
);
|
||||
}
|
||||
|
||||
function BotCard({
|
||||
connection,
|
||||
busy,
|
||||
actionError,
|
||||
removing,
|
||||
onReconnect,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
onCancelRemove,
|
||||
cardRef,
|
||||
removeButtonRef,
|
||||
}) {
|
||||
const { bot, health, state, connected } = connection;
|
||||
const stateForDisplay = busy === "reconnect"
|
||||
? "connecting"
|
||||
: state;
|
||||
const tone = stateForDisplay === "connected"
|
||||
? "success"
|
||||
: stateForDisplay === "connecting"
|
||||
? "warning"
|
||||
: "error";
|
||||
const summary = actionError?.message
|
||||
?? connection.error?.message
|
||||
?? health.summary;
|
||||
const titleId = `bxf-bot-${connection.botId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||
const domainLabel = bot.domain === "lark" ? "Lark" : "飞书";
|
||||
|
||||
return h("article", {
|
||||
className: "bxf-card bxf-botCard",
|
||||
"aria-labelledby": titleId,
|
||||
"data-bot-id": connection.botId,
|
||||
tabIndex: -1,
|
||||
ref: cardRef,
|
||||
},
|
||||
h("div", { className: "bxf-cardBody" },
|
||||
h("div", { className: "bxf-connectedTop" },
|
||||
h("div", { className: "bxf-botIdentity" },
|
||||
bot.avatarUrl
|
||||
? h("div", { className: "bxf-avatar" }, h("img", { src: bot.avatarUrl, alt: "" }))
|
||||
: h(BrandMark, { compact: true }),
|
||||
h("div", { className: "bxf-botName" },
|
||||
h("h3", { id: titleId, title: bot.name }, bot.name),
|
||||
h("p", null, bot.tenantName ? `${bot.tenantName} · ${domainLabel}机器人` : `${domainLabel}机器人`)),
|
||||
),
|
||||
h("div", { className: "bxf-healthPill", "data-health": stateForDisplay },
|
||||
h("span", { className: "bxf-dot", "data-tone": tone }),
|
||||
h("span", null, HEALTH_LABELS[stateForDisplay] ?? "状态未知")),
|
||||
),
|
||||
h("dl", { className: "bxf-statusGrid" },
|
||||
h("div", { className: "bxf-metric" }, h("dt", null, "消息通道"),
|
||||
h("dd", null, connected ? "长连接" : stateForDisplay === "connecting" ? "连接中" : "已断开")),
|
||||
h("div", { className: "bxf-metric" }, h("dt", null, "应用标识"),
|
||||
h("dd", { title: bot.appIdMasked }, bot.appIdMasked ?? "已安全保存")),
|
||||
h("div", { className: "bxf-metric" }, h("dt", null, "最近检查"),
|
||||
h("dd", null, formatCheckedTime(health.lastCheckedAt))),
|
||||
),
|
||||
h("div", { className: "bxf-divider" }),
|
||||
h("div", { className: "bxf-connectedFooter" },
|
||||
h("div", { className: "bxf-healthSummary", "data-error": actionError || connection.error ? "true" : undefined },
|
||||
h("strong", null, "连接状态:"), h("span", null, summary)),
|
||||
h("div", { className: "bxf-actions bxf-botActions" },
|
||||
h(Button, {
|
||||
size: "small", icon: h(RefreshIcon), onClick: onReconnect,
|
||||
disabled: Boolean(busy), "aria-busy": busy === "reconnect" ? "true" : undefined,
|
||||
"aria-label": `${connected ? "检查连接" : "重试连接"}${bot.name}`,
|
||||
}, busy === "reconnect" ? (connected ? "检查中…" : "正在连接…") : connected ? "检查连接" : "重试连接"),
|
||||
h(Button, {
|
||||
size: "small", kind: "danger", onClick: onRequestRemove,
|
||||
disabled: Boolean(busy), ref: removeButtonRef,
|
||||
"aria-label": `从 DeepSeek Harness 移除${bot.name}`,
|
||||
}, "移除接入")),
|
||||
),
|
||||
),
|
||||
removing
|
||||
? h(RemoveConfirmation, {
|
||||
bot: connection,
|
||||
busy: busy === "delete",
|
||||
onConfirm: onConfirmRemove,
|
||||
onCancel: onCancelRemove,
|
||||
})
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
function BotList(props) {
|
||||
return h("section", { className: "bxf-listSection", "aria-labelledby": "bxf-bot-list-title" },
|
||||
h("div", { className: "bxf-listHeading" },
|
||||
h("h3", { id: "bxf-bot-list-title" }, "已接入的机器人"),
|
||||
h("span", null, `${props.bots.length} 个`)),
|
||||
h("ul", { className: "bxf-botList", role: "list" },
|
||||
props.bots.map((bot) => h("li", { key: bot.botId },
|
||||
h(BotCard, {
|
||||
connection: bot,
|
||||
busy: props.busyByBot[bot.botId],
|
||||
actionError: props.errorsByBot[bot.botId],
|
||||
removing: props.removeTargetId === bot.botId,
|
||||
onReconnect: () => props.onReconnect(bot),
|
||||
onRequestRemove: () => props.onRequestRemove(bot),
|
||||
onConfirmRemove: () => props.onConfirmRemove(bot),
|
||||
onCancelRemove: props.onCancelRemove,
|
||||
cardRef: (node) => props.setCardRef(bot.botId, node),
|
||||
removeButtonRef: (node) => props.setRemoveButtonRef(bot.botId, node),
|
||||
}),
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
function PageError({ error, onRetry, busy }) {
|
||||
return h("div", { className: "bxf-card" },
|
||||
h("div", { className: "bxf-error", role: "alert" },
|
||||
h("div", { className: "bxf-errorIcon" }, h(AlertIcon)),
|
||||
h("div", null,
|
||||
h("h3", null, "无法读取飞书机器人"),
|
||||
h("p", null, error.message),
|
||||
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
|
||||
h("div", { className: "bxf-actions" },
|
||||
h(Button, { kind: "primary", icon: h(RefreshIcon), onClick: onRetry, disabled: busy },
|
||||
busy ? "重试中…" : "重新读取"))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
|
||||
|
||||
export function FeishuSettingsTab({ rpcCall }) {
|
||||
const [model, setModel] = React.useState({
|
||||
phase: "loading",
|
||||
revision: 0,
|
||||
bots: [],
|
||||
totals: EMPTY_TOTALS,
|
||||
provisioning: null,
|
||||
pageError: null,
|
||||
statusError: null,
|
||||
});
|
||||
const [pageBusy, setPageBusy] = React.useState(false);
|
||||
const [provisionBusy, setProvisionBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [errorsByBot, setErrorsByBot] = React.useState({});
|
||||
const [removeTargetId, setRemoveTargetId] = React.useState(null);
|
||||
const [announcement, setAnnouncement] = React.useState("");
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
const [focusBotId, setFocusBotId] = React.useState(null);
|
||||
const cardRefs = React.useRef(new Map());
|
||||
const removeButtonRefs = React.useRef(new Map());
|
||||
const addButtonRef = React.useRef(null);
|
||||
|
||||
const announce = React.useCallback((message) => {
|
||||
setAnnouncement("");
|
||||
if (!message) return;
|
||||
window.requestAnimationFrame(() => setAnnouncement(message));
|
||||
}, []);
|
||||
|
||||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||||
}, [rpcCall]);
|
||||
|
||||
const mergeSnapshot = React.useCallback((snapshot, { restoreProvisioning = true } = {}) => {
|
||||
setModel((current) => {
|
||||
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
|
||||
let provisioning = current.provisioning;
|
||||
if (!provisioning && restoreProvisioning && snapshot.provisioning) {
|
||||
provisioning = {
|
||||
phase: snapshot.state === "connecting" ? "connecting" : "qr",
|
||||
...snapshot.provisioning,
|
||||
durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
|
||||
expired: snapshot.provisioning.expiresAt <= Date.now(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
phase: "ready",
|
||||
revision: snapshot.revision,
|
||||
bots: snapshot.bots,
|
||||
totals: snapshot.totals,
|
||||
provisioning,
|
||||
pageError: null,
|
||||
statusError: null,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = true } = {}) => {
|
||||
if (!silent) setPageBusy(true);
|
||||
try {
|
||||
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal));
|
||||
mergeSnapshot(snapshot, { restoreProvisioning });
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") return undefined;
|
||||
const presented = presentError(error);
|
||||
setModel((current) => current.phase === "loading" || !silent
|
||||
? { ...current, phase: "error", pageError: presented }
|
||||
: { ...current, statusError: presented });
|
||||
return undefined;
|
||||
} finally {
|
||||
if (!silent) setPageBusy(false);
|
||||
}
|
||||
}, [invoke, mergeSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadStatus({ signal: controller.signal });
|
||||
return () => controller.abort();
|
||||
}, [loadStatus]);
|
||||
|
||||
// One list request refreshes every bot. This continues while a new bot is
|
||||
// being provisioned so existing connections never disappear from the UI.
|
||||
React.useEffect(() => {
|
||||
if (model.phase !== "ready") return undefined;
|
||||
const controller = new AbortController();
|
||||
let inFlight = false;
|
||||
const timer = window.setInterval(async () => {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
await loadStatus({ signal: controller.signal, silent: true });
|
||||
inFlight = false;
|
||||
}, 15_000);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [loadStatus, model.phase]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focusBotId) return;
|
||||
const node = cardRefs.current.get(focusBotId);
|
||||
if (!node) return;
|
||||
node.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
|
||||
node.focus({ preventScroll: true });
|
||||
setFocusBotId(null);
|
||||
}, [focusBotId, model.bots]);
|
||||
|
||||
const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
|
||||
setProvisionBusy(true);
|
||||
announce("");
|
||||
const previousAttemptId = model.provisioning?.attemptId;
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
phase: current.phase === "loading" ? "ready" : current.phase,
|
||||
provisioning: { phase: "creating" },
|
||||
}));
|
||||
try {
|
||||
if (replace && previousAttemptId) {
|
||||
await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
|
||||
}
|
||||
const provision = normalizeProvisioning(await invoke(
|
||||
FEISHU_ENDPOINTS.beginProvisioning,
|
||||
{ locale: "zh-CN" },
|
||||
));
|
||||
const timestamp = Date.now();
|
||||
setNow(timestamp);
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
provisioning: {
|
||||
phase: "qr",
|
||||
...provision,
|
||||
durationMs: Math.max(1, provision.expiresAt - timestamp),
|
||||
expired: false,
|
||||
},
|
||||
}));
|
||||
announce("授权二维码已生成,请使用飞书扫码。");
|
||||
} catch (error) {
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
provisioning: { phase: "error", error: presentError(error) },
|
||||
}));
|
||||
} finally {
|
||||
setProvisionBusy(false);
|
||||
}
|
||||
}, [announce, invoke, model.provisioning?.attemptId]);
|
||||
|
||||
const cancelProvisioning = React.useCallback(async () => {
|
||||
const attemptId = model.provisioning?.attemptId;
|
||||
setProvisionBusy(true);
|
||||
try {
|
||||
if (attemptId) await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId });
|
||||
setModel((current) => ({ ...current, provisioning: null }));
|
||||
announce("已取消添加机器人。");
|
||||
await loadStatus({ silent: true, restoreProvisioning: false });
|
||||
window.requestAnimationFrame(() => addButtonRef.current?.focus());
|
||||
} catch (error) {
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
provisioning: { phase: "error", attemptId, error: presentError(error) },
|
||||
}));
|
||||
} finally {
|
||||
setProvisionBusy(false);
|
||||
}
|
||||
}, [announce, invoke, loadStatus, model.provisioning?.attemptId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const provision = model.provisioning;
|
||||
if (!provision || provision.phase !== "qr" || provision.expired) return undefined;
|
||||
const tick = () => {
|
||||
const timestamp = Date.now();
|
||||
setNow(timestamp);
|
||||
if (timestamp >= provision.expiresAt) {
|
||||
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
||||
? { ...current, provisioning: { ...current.provisioning, expired: true } }
|
||||
: current);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [model.provisioning]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const provision = model.provisioning;
|
||||
if (!provision
|
||||
|| !["qr", "connecting"].includes(provision.phase)
|
||||
|| !provision.attemptId
|
||||
|| provision.expired) return undefined;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const result = normalizePollResult(await invoke(
|
||||
FEISHU_ENDPOINTS.pollProvisioning,
|
||||
{ attemptId: provision.attemptId },
|
||||
controller.signal,
|
||||
));
|
||||
if (result.status === "connected") {
|
||||
const snapshot = await loadStatus({ signal: controller.signal, silent: true, restoreProvisioning: false });
|
||||
const newBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
|
||||
if (!snapshot) {
|
||||
throw new Error("机器人已经创建,但暂时无法确认连接状态");
|
||||
}
|
||||
if (!newBot?.connected) {
|
||||
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
||||
? { ...current, provisioning: { ...current.provisioning, phase: "connecting" } }
|
||||
: current);
|
||||
return;
|
||||
}
|
||||
setModel((current) => ({ ...current, provisioning: null }));
|
||||
announce(newBot
|
||||
? `${newBot.bot.name}已连接,可以在飞书中开始聊天。`
|
||||
: "新飞书机器人已连接,可以开始聊天。");
|
||||
if (result.botId) setFocusBotId(result.botId);
|
||||
return;
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
const error = new Error(result.message ?? "飞书应用创建失败");
|
||||
error.code = "FEISHU_PROVISION_FAILED";
|
||||
throw error;
|
||||
}
|
||||
if (result.status === "expired") {
|
||||
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
||||
? { ...current, provisioning: { ...current.provisioning, phase: "qr", expired: true } }
|
||||
: current);
|
||||
return;
|
||||
}
|
||||
setModel((current) => {
|
||||
if (current.provisioning?.attemptId !== provision.attemptId) return current;
|
||||
const next = result.provisioning ?? current.provisioning;
|
||||
return {
|
||||
...current,
|
||||
provisioning: {
|
||||
...current.provisioning,
|
||||
...next,
|
||||
phase: ["scanned", "connecting"].includes(result.status) ? "connecting" : "qr",
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") return;
|
||||
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
||||
? {
|
||||
...current,
|
||||
provisioning: {
|
||||
phase: "error",
|
||||
attemptId: provision.attemptId,
|
||||
error: presentError(error),
|
||||
},
|
||||
}
|
||||
: current);
|
||||
}
|
||||
}, provision.pollIntervalMs);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [announce, invoke, loadStatus, model.provisioning]);
|
||||
|
||||
const setBotBusy = React.useCallback((botId, value) => {
|
||||
setBusyByBot((current) => {
|
||||
const next = { ...current };
|
||||
if (value) next[botId] = value;
|
||||
else delete next[botId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setBotError = React.useCallback((botId, error) => {
|
||||
setErrorsByBot((current) => {
|
||||
const next = { ...current };
|
||||
if (error) next[botId] = presentError(error);
|
||||
else delete next[botId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reconnectOneBot = React.useCallback(async (connection) => {
|
||||
const { botId, bot } = connection;
|
||||
setBotBusy(botId, "reconnect");
|
||||
setBotError(botId, null);
|
||||
try {
|
||||
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId }));
|
||||
mergeSnapshot(snapshot);
|
||||
const refreshed = snapshot.bots.find((item) => item.botId === botId);
|
||||
if (!refreshed?.connected) {
|
||||
const error = new Error(
|
||||
refreshed?.error?.message ?? refreshed?.health.summary ?? "机器人仍未连接",
|
||||
);
|
||||
error.code = refreshed?.error?.code ?? "FEISHU_BOT_OFFLINE";
|
||||
throw error;
|
||||
}
|
||||
announce(connection.connected
|
||||
? `${bot.name}连接检查完成。`
|
||||
: `${bot.name}已重新连接。`);
|
||||
} catch (error) {
|
||||
setBotError(botId, error);
|
||||
announce(`${bot.name}操作失败,请查看机器人状态。`);
|
||||
} finally {
|
||||
setBotBusy(botId, null);
|
||||
}
|
||||
}, [announce, invoke, mergeSnapshot, setBotBusy, setBotError]);
|
||||
|
||||
const requestRemove = React.useCallback((connection) => {
|
||||
setRemoveTargetId(connection.botId);
|
||||
}, []);
|
||||
|
||||
const cancelRemove = React.useCallback(() => {
|
||||
const botId = removeTargetId;
|
||||
setRemoveTargetId(null);
|
||||
window.requestAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus());
|
||||
}, [removeTargetId]);
|
||||
|
||||
const confirmRemove = React.useCallback(async (connection) => {
|
||||
const { botId, bot } = connection;
|
||||
setBotBusy(botId, "delete");
|
||||
setBotError(botId, null);
|
||||
try {
|
||||
await invoke(FEISHU_ENDPOINTS.deleteBot, { botId, confirm: true });
|
||||
setRemoveTargetId(null);
|
||||
setModel((current) => {
|
||||
const bots = current.bots.filter((item) => item.botId !== botId);
|
||||
return {
|
||||
...current,
|
||||
bots,
|
||||
totals: {
|
||||
configured: bots.length,
|
||||
connected: bots.filter((item) => item.connected).length,
|
||||
},
|
||||
};
|
||||
});
|
||||
announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`);
|
||||
await loadStatus({ silent: true });
|
||||
window.requestAnimationFrame(() => addButtonRef.current?.focus());
|
||||
} catch (error) {
|
||||
setBotError(botId, error);
|
||||
announce(`${bot.name}移除失败,请重试。`);
|
||||
} finally {
|
||||
setBotBusy(botId, null);
|
||||
}
|
||||
}, [announce, invoke, loadStatus, setBotBusy, setBotError]);
|
||||
|
||||
const provision = model.provisioning;
|
||||
let provisionContent = null;
|
||||
if (provision?.phase === "creating") {
|
||||
provisionContent = h(ProvisionProgress, { phase: "creating", busy: provisionBusy });
|
||||
} else if (provision?.phase === "qr") {
|
||||
provisionContent = h(QrPane, {
|
||||
provision, now,
|
||||
onRefresh: () => void startProvisioning({ replace: true }),
|
||||
onCancel: () => void cancelProvisioning(),
|
||||
busy: provisionBusy || model.phase !== "ready",
|
||||
});
|
||||
} else if (provision?.phase === "connecting") {
|
||||
provisionContent = h(ProvisionProgress, {
|
||||
phase: "connecting",
|
||||
onCancel: () => void cancelProvisioning(),
|
||||
busy: provisionBusy,
|
||||
});
|
||||
} else if (provision?.phase === "error") {
|
||||
provisionContent = h(ProvisionError, {
|
||||
error: provision.error,
|
||||
onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
|
||||
onCancel: () => void cancelProvisioning(),
|
||||
busy: provisionBusy,
|
||||
});
|
||||
}
|
||||
|
||||
const setCardRef = React.useCallback((botId, node) => {
|
||||
if (node) cardRefs.current.set(botId, node);
|
||||
else cardRefs.current.delete(botId);
|
||||
}, []);
|
||||
const setRemoveButtonRef = React.useCallback((botId, node) => {
|
||||
if (node) removeButtonRefs.current.set(botId, node);
|
||||
else removeButtonRefs.current.delete(botId);
|
||||
}, []);
|
||||
|
||||
return h("section", { className: "bxf-page", "aria-label": "飞书机器人设置" },
|
||||
h(Heading, {
|
||||
totals: model.totals,
|
||||
onAdd: () => void startProvisioning(),
|
||||
adding: Boolean(provision),
|
||||
busy: provisionBusy,
|
||||
addButtonRef,
|
||||
}),
|
||||
h("div", {
|
||||
className: "bxf-visuallyHidden", role: "status", "aria-live": "polite", "aria-atomic": "true",
|
||||
}, announcement),
|
||||
model.statusError
|
||||
? h("div", { className: "bxf-statusNotice", role: "status" },
|
||||
h(AlertIcon, { size: 16 }),
|
||||
h("span", null, `状态自动刷新失败:${model.statusError.message}`),
|
||||
h(Button, { size: "small", onClick: () => void loadStatus({ silent: true }), disabled: pageBusy }, "立即重试"))
|
||||
: null,
|
||||
model.phase === "loading"
|
||||
? h(LoadingView)
|
||||
: model.phase === "error"
|
||||
? h(PageError, {
|
||||
error: model.pageError ?? { message: "无法读取连接状态" },
|
||||
onRetry: () => void loadStatus(),
|
||||
busy: pageBusy,
|
||||
})
|
||||
: h(React.Fragment, null,
|
||||
provisionContent,
|
||||
model.bots.length === 0 && !provision
|
||||
? h(EmptyView, { onStart: () => void startProvisioning(), busy: provisionBusy })
|
||||
: null,
|
||||
model.bots.length > 0
|
||||
? h(BotList, {
|
||||
bots: model.bots,
|
||||
busyByBot,
|
||||
errorsByBot,
|
||||
removeTargetId,
|
||||
onReconnect: (bot) => void reconnectOneBot(bot),
|
||||
onRequestRemove: requestRemove,
|
||||
onConfirmRemove: (bot) => void confirmRemove(bot),
|
||||
onCancelRemove: cancelRemove,
|
||||
setCardRef,
|
||||
setRemoveButtonRef,
|
||||
})
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(
|
||||
() => installFeishuStyles(),
|
||||
"feishu-settings: install client styles",
|
||||
);
|
||||
|
||||
const rpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(FEISHU_RPC_CHANNEL, endpoint, payload, signal);
|
||||
|
||||
ctx.slots.inject("settings.plugins.tab", () =>
|
||||
ctx.slots.register(
|
||||
{
|
||||
name: "settings.plugins.tab",
|
||||
id: "feishu",
|
||||
order: 20,
|
||||
label: "飞书",
|
||||
inject: () => ({ rpcCall }),
|
||||
},
|
||||
FeishuSettingsTab,
|
||||
),
|
||||
);
|
||||
}
|
||||
527
plugin-src/client/channels/feishu/styles.js
Normal file
527
plugin-src/client/channels/feishu/styles.js
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
export const FEISHU_STYLE_ID = "beihuixinghe-dsh-feishu-settings";
|
||||
|
||||
const CSS = String.raw`
|
||||
.bxf-page {
|
||||
--bxf-accent: var(--dsw-alias-state-business-primary, #3370ff);
|
||||
--bxf-success: var(--dsw-alias-state-success-primary, #20a162);
|
||||
--bxf-warning: var(--dsw-alias-state-warning-primary, #d97706);
|
||||
--bxf-error: var(--dsw-alias-state-error-primary, #d54941);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
color: var(--dsw-alias-label-primary, #1f2329);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container-type: inline-size;
|
||||
gap: 18px;
|
||||
padding: 2px 0 24px;
|
||||
}
|
||||
|
||||
.bxf-page *, .bxf-page *::before, .bxf-page *::after { box-sizing: border-box; }
|
||||
|
||||
.bxf-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.bxf-headingCopy { min-width: 0; }
|
||||
.bxf-heading h2, .bxf-heading p, .bxf-card h3, .bxf-card p { margin: 0; }
|
||||
|
||||
.bxf-eyebrow {
|
||||
color: var(--dsw-alias-label-tertiary, #8f959e);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.bxf-heading h2 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -.015em;
|
||||
}
|
||||
|
||||
.bxf-heading p {
|
||||
max-width: 540px;
|
||||
color: var(--dsw-alias-label-secondary, #646a73);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
margin-top: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bxf-headingTools {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bxf-totalBadge {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 3px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
color: var(--dsw-alias-label-secondary, #646a73);
|
||||
background: var(--dsw-alias-bg-module-platform, #f2f3f5);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bxf-totalBadge strong { color: var(--bxf-success); font-size: 13px; }
|
||||
|
||||
.bxf-localBadge {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
border: 1px solid var(--dsw-alias-border-l2, #dee0e3);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
color: var(--dsw-alias-label-secondary, #646a73);
|
||||
background: var(--dsw-alias-bg-layer-1, #fff);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bxf-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2, #dee0e3);
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-alias-bg-layer-3, #fff);
|
||||
box-shadow: var(--dsw-shadow-lv1, 0 3px 12px rgba(31, 35, 41, .05));
|
||||
}
|
||||
|
||||
.bxf-card::before {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 88px;
|
||||
background:
|
||||
radial-gradient(circle at 86% -35%, color-mix(in srgb, var(--bxf-accent) 18%, transparent), transparent 68%);
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
.bxf-cardBody { position: relative; padding: 24px; }
|
||||
|
||||
.bxf-intro {
|
||||
min-height: 250px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 172px;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bxf-introCopy { max-width: 500px; }
|
||||
|
||||
.bxf-stateLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--dsw-alias-label-secondary, #646a73);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.bxf-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-tertiary, #8f959e);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--dsw-alias-label-tertiary, #8f959e) 12%, transparent);
|
||||
}
|
||||
|
||||
.bxf-dot[data-tone="success"] {
|
||||
background: var(--bxf-success);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--bxf-success) 13%, transparent);
|
||||
}
|
||||
|
||||
.bxf-dot[data-tone="warning"] {
|
||||
background: var(--bxf-warning);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--bxf-warning) 13%, transparent);
|
||||
}
|
||||
|
||||
.bxf-dot[data-tone="error"] {
|
||||
background: var(--bxf-error);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--bxf-error) 13%, transparent);
|
||||
}
|
||||
|
||||
.bxf-intro h3 {
|
||||
font-size: 24px;
|
||||
line-height: 34px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
|
||||
.bxf-introCopy > p {
|
||||
max-width: 490px;
|
||||
color: var(--dsw-alias-label-secondary, #646a73);
|
||||
font-size: 14px;
|
||||
line-height: 23px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.bxf-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
color: var(--dsw-alias-label-tertiary, #8f959e);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bxf-note svg { flex: none; margin-top: 1px; }
|
||||
|
||||
.bxf-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.bxf-button {
|
||||
appearance: none;
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--dsw-alias-border-l2, #dee0e3);
|
||||
border-radius: 8px;
|
||||
padding: 7px 13px;
|
||||
color: var(--dsw-alias-label-primary, #1f2329);
|
||||
background: var(--dsw-alias-bg-layer-1, #fff);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
line-height: 20px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background .15s var(--ds-ease-in-out, ease), border-color .15s var(--ds-ease-in-out, ease), transform .15s var(--ds-ease-in-out, ease);
|
||||
}
|
||||
|
||||
.bxf-button:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover, #f2f3f5);
|
||||
border-color: var(--dsw-alias-border-l1, #c9cdd4);
|
||||
}
|
||||
|
||||
.bxf-button:active:not(:disabled) { transform: translateY(1px); }
|
||||
|
||||
.bxf-button:focus-visible, .bxf-link:focus-visible {
|
||||
outline: 2px solid var(--bxf-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bxf-button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
|
||||
.bxf-button[data-kind="primary"] {
|
||||
border-color: var(--bxf-accent);
|
||||
color: #fff;
|
||||
background: var(--bxf-accent);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--bxf-accent) 24%, transparent);
|
||||
}
|
||||
|
||||
.bxf-button[data-kind="primary"]:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--bxf-accent) 86%, #000);
|
||||
background: color-mix(in srgb, var(--bxf-accent) 90%, #000);
|
||||
}
|
||||
|
||||
.bxf-button[data-kind="danger"] { color: var(--bxf-error); }
|
||||
.bxf-button[data-size="small"] { min-height: 32px; padding: 5px 10px; font-size: 12px; }
|
||||
|
||||
.bxf-provisionCard {
|
||||
border-color: color-mix(in srgb, var(--bxf-accent) 32%, var(--dsw-alias-border-l2, #dee0e3));
|
||||
}
|
||||
|
||||
.bxf-markStage {
|
||||
position: relative;
|
||||
width: 156px;
|
||||
height: 156px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.bxf-markStage::before, .bxf-markStage::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bxf-markStage::before {
|
||||
inset: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--bxf-accent) 18%, var(--dsw-alias-border-l2, #dee0e3));
|
||||
background: color-mix(in srgb, var(--bxf-accent) 4%, var(--dsw-alias-bg-layer-1, #fff));
|
||||
}
|
||||
|
||||
.bxf-markStage::after {
|
||||
inset: 0;
|
||||
border: 1px dashed color-mix(in srgb, var(--bxf-accent) 16%, transparent);
|
||||
animation: bxf-rotate 18s linear infinite;
|
||||
}
|
||||
|
||||
.bxf-brandMark {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
background: var(--bxf-accent);
|
||||
box-shadow: 0 12px 28px color-mix(in srgb, var(--bxf-accent) 28%, transparent);
|
||||
}
|
||||
|
||||
.bxf-qrLayout {
|
||||
display: grid;
|
||||
grid-template-columns: 236px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.bxf-qrColumn { min-width: 0; }
|
||||
|
||||
.bxf-qrFrame {
|
||||
position: relative;
|
||||
width: 222px;
|
||||
height: 222px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--dsw-alias-border-l2, #dee0e3);
|
||||
border-radius: 14px;
|
||||
padding: 13px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(31, 35, 41, .07);
|
||||
}
|
||||
|
||||
.bxf-qrFrame::before, .bxf-qrFrame::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-color: var(--bxf-accent);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.bxf-qrFrame::before { inset: -3px auto auto -3px; border-width: 2px 0 0 2px; border-radius: 5px 0 0; }
|
||||
.bxf-qrFrame::after { inset: auto -3px -3px auto; border-width: 0 2px 2px 0; border-radius: 0 0 5px; }
|
||||
.bxf-qrFrame img { width: 100%; height: 100%; display: block; object-fit: contain; }
|
||||
|
||||
.bxf-qrFallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
color: var(--bxf-accent);
|
||||
background: #f7f9ff;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.bxf-qrFallback span { display: block; color: #646a73; font-size: 12px; line-height: 18px; margin-top: 8px; }
|
||||
|
||||
.bxf-expiredOverlay {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
color: #1f2329;
|
||||
background: rgba(255, 255, 255, .94);
|
||||
backdrop-filter: blur(3px);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bxf-countdown {
|
||||
width: 222px;
|
||||
color: var(--dsw-alias-label-tertiary, #8f959e);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.bxf-countdownTop { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.bxf-progress { height: 3px; overflow: hidden; border-radius: 99px; background: var(--dsw-alias-bg-module-platform, #f2f3f5); margin-top: 6px; }
|
||||
.bxf-progress > span { display: block; width: var(--bxf-progress, 100%); height: 100%; border-radius: inherit; background: var(--bxf-accent); transition: width 1s linear; }
|
||||
|
||||
.bxf-qrCopy h3 { font-size: 20px; line-height: 29px; font-weight: 650; }
|
||||
.bxf-qrCopy > p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 21px; margin-top: 7px; }
|
||||
|
||||
.bxf-steps { counter-reset: bxf-step; display: flex; flex-direction: column; gap: 11px; margin: 20px 0 0; padding: 0; list-style: none; }
|
||||
.bxf-steps li { counter-increment: bxf-step; display: grid; grid-template-columns: 23px minmax(0, 1fr); align-items: start; gap: 9px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; }
|
||||
.bxf-steps li::before { content: counter(bxf-step); width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid var(--dsw-alias-border-l2, #dee0e3); border-radius: 50%; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font-size: 10px; font-weight: 650; }
|
||||
|
||||
.bxf-connecting { min-height: 292px; display: grid; place-items: center; text-align: center; padding: 36px 24px; }
|
||||
.bxf-connectingCopy { max-width: 430px; }
|
||||
.bxf-orbit { position: relative; width: 86px; height: 86px; display: grid; place-items: center; margin: 0 auto 22px; }
|
||||
.bxf-orbit::before, .bxf-orbit::after { content: ""; position: absolute; border-radius: 50%; }
|
||||
.bxf-orbit::before { inset: 3px; border: 1px solid color-mix(in srgb, var(--bxf-accent) 24%, transparent); animation: bxf-pulse 1.8s var(--ds-ease-in-out, ease) infinite; }
|
||||
.bxf-orbit::after { inset: 0; border: 2px solid transparent; border-top-color: var(--bxf-accent); animation: bxf-rotate 1.2s linear infinite; }
|
||||
.bxf-orbitCore { width: 50px; height: 50px; display: grid; place-items: center; border-radius: 16px; color: var(--bxf-accent); background: color-mix(in srgb, var(--bxf-accent) 9%, var(--dsw-alias-bg-layer-1, #fff)); }
|
||||
.bxf-connecting h3 { font-size: 20px; line-height: 29px; }
|
||||
.bxf-connecting p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 21px; margin-top: 7px; }
|
||||
.bxf-connectingCompact { min-height: 248px; }
|
||||
|
||||
.bxf-inlineError {
|
||||
min-height: 190px;
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
align-content: center;
|
||||
gap: 15px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.bxf-inlineError h3 { font-size: 17px; line-height: 25px; margin: 0; }
|
||||
.bxf-inlineError p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 21px; margin-top: 5px; overflow-wrap: anywhere; }
|
||||
|
||||
.bxf-listSection { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bxf-listHeading { min-height: 28px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 2px; }
|
||||
.bxf-listHeading h3 { font-size: 14px; line-height: 22px; font-weight: 650; margin: 0; }
|
||||
.bxf-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; }
|
||||
.bxf-botList { display: flex; flex-direction: column; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||
.bxf-botList > li { min-width: 0; }
|
||||
.bxf-botCard:focus { outline: none; }
|
||||
.bxf-botCard:focus-visible { outline: 2px solid var(--bxf-accent); outline-offset: 2px; }
|
||||
|
||||
.bxf-connectedTop { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.bxf-botIdentity { min-width: 0; display: flex; align-items: center; gap: 13px; }
|
||||
.bxf-avatar { flex: none; width: 48px; height: 48px; display: grid; place-items: center; overflow: hidden; border-radius: 14px; color: var(--bxf-accent); background: color-mix(in srgb, var(--bxf-accent) 9%, var(--dsw-alias-bg-layer-1, #fff)); }
|
||||
.bxf-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.bxf-botName { min-width: 0; }
|
||||
.bxf-botName h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 17px; line-height: 24px; font-weight: 650; }
|
||||
.bxf-botName p { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: 18px; margin-top: 2px; }
|
||||
|
||||
.bxf-healthPill { flex: none; display: inline-flex; align-items: center; gap: 7px; min-height: 28px; border-radius: 999px; padding: 4px 10px; color: var(--bxf-success); background: color-mix(in srgb, var(--bxf-success) 10%, transparent); font-size: 12px; font-weight: 600; line-height: 18px; }
|
||||
.bxf-healthPill[data-health="degraded"], .bxf-healthPill[data-health="checking"], .bxf-healthPill[data-health="connecting"] { color: var(--bxf-warning); background: color-mix(in srgb, var(--bxf-warning) 10%, transparent); }
|
||||
.bxf-healthPill[data-health="offline"], .bxf-healthPill[data-health="error"] { color: var(--bxf-error); background: color-mix(in srgb, var(--bxf-error) 10%, transparent); }
|
||||
|
||||
.bxf-statusGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 22px; }
|
||||
.bxf-metric { min-width: 0; border: 1px solid var(--dsw-alias-border-l2, #dee0e3); border-radius: 9px; padding: 12px 13px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.bxf-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 17px; }
|
||||
.bxf-metric dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 18px; font-weight: 550; margin: 3px 0 0; }
|
||||
|
||||
.bxf-divider { height: 1px; background: var(--dsw-alias-border-l2, #dee0e3); margin: 22px 0 0; }
|
||||
.bxf-connectedFooter { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 17px; }
|
||||
.bxf-healthSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
|
||||
.bxf-healthSummary strong { color: var(--dsw-alias-label-primary, #1f2329); font-weight: 600; }
|
||||
.bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
|
||||
.bxf-botActions { margin-top: 0; justify-content: flex-end; }
|
||||
|
||||
.bxf-confirm {
|
||||
border-top: 1px solid var(--dsw-alias-border-l2, #dee0e3);
|
||||
background: color-mix(in srgb, var(--bxf-error) 4%, var(--dsw-alias-bg-module-platform, #f7f8fa));
|
||||
padding: 17px 24px 20px;
|
||||
}
|
||||
.bxf-confirm:focus { outline: none; }
|
||||
.bxf-confirm h4 { font-size: 13px; line-height: 20px; margin: 0; }
|
||||
.bxf-confirm p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; margin: 4px 0 0; }
|
||||
.bxf-confirm .bxf-actions { margin-top: 12px; }
|
||||
|
||||
.bxf-error { min-height: 252px; display: grid; grid-template-columns: 44px minmax(0, 1fr); align-content: center; gap: 15px; padding: 30px; }
|
||||
.bxf-errorIcon { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 13px; color: var(--bxf-error); background: color-mix(in srgb, var(--bxf-error) 9%, transparent); }
|
||||
.bxf-error h3 { font-size: 17px; line-height: 25px; }
|
||||
.bxf-error p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 21px; margin-top: 5px; overflow-wrap: anywhere; }
|
||||
.bxf-errorCode { display: inline-block; color: var(--dsw-alias-label-tertiary, #8f959e); font-family: var(--ds-font-family-code, monospace); font-size: 11px; margin-top: 7px; }
|
||||
|
||||
.bxf-statusNotice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--bxf-warning) 28%, var(--dsw-alias-border-l2, #dee0e3));
|
||||
border-radius: 10px;
|
||||
padding: 9px 11px;
|
||||
color: var(--dsw-alias-label-secondary, #646a73);
|
||||
background: color-mix(in srgb, var(--bxf-warning) 5%, var(--dsw-alias-bg-layer-1, #fff));
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.bxf-statusNotice > svg { flex: none; color: var(--bxf-warning); }
|
||||
.bxf-statusNotice > span { min-width: 0; flex: 1; overflow-wrap: anywhere; }
|
||||
|
||||
.bxf-skeleton { min-height: 260px; padding: 28px; }
|
||||
.bxf-skeletonLine { height: 12px; border-radius: 999px; background: linear-gradient(90deg, var(--dsw-alias-bg-module-platform, #f2f3f5), color-mix(in srgb, var(--dsw-alias-label-tertiary, #8f959e) 10%, transparent), var(--dsw-alias-bg-module-platform, #f2f3f5)); background-size: 220% 100%; animation: bxf-shimmer 1.5s linear infinite; }
|
||||
.bxf-skeletonLine:nth-child(1) { width: 92px; }
|
||||
.bxf-skeletonLine:nth-child(2) { width: 44%; height: 22px; margin-top: 23px; }
|
||||
.bxf-skeletonLine:nth-child(3) { width: 72%; margin-top: 14px; }
|
||||
.bxf-skeletonLine:nth-child(4) { width: 58%; margin-top: 9px; }
|
||||
.bxf-skeletonBox { width: 138px; height: 38px; border-radius: 8px; background: var(--dsw-alias-bg-module-platform, #f2f3f5); margin-top: 28px; }
|
||||
|
||||
.bxf-visuallyHidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
|
||||
@keyframes bxf-rotate { to { transform: rotate(360deg); } }
|
||||
@keyframes bxf-pulse { 0%, 100% { transform: scale(.9); opacity: .45; } 50% { transform: scale(1.08); opacity: 1; } }
|
||||
@keyframes bxf-shimmer { to { background-position: -220% 0; } }
|
||||
|
||||
@container (max-width: 620px) {
|
||||
.bxf-heading { flex-direction: column; gap: 10px; }
|
||||
.bxf-headingTools { width: 100%; justify-content: flex-start; }
|
||||
.bxf-headingTools .bxf-button { margin-left: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.bxf-heading { flex-direction: column; gap: 10px; }
|
||||
.bxf-headingTools { width: 100%; justify-content: flex-start; }
|
||||
.bxf-headingTools .bxf-button { margin-left: auto; }
|
||||
.bxf-intro { grid-template-columns: minmax(0, 1fr); }
|
||||
.bxf-markStage { display: none; }
|
||||
.bxf-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; }
|
||||
.bxf-qrCopy { width: 100%; }
|
||||
.bxf-statusGrid { grid-template-columns: minmax(0, 1fr); }
|
||||
.bxf-connectedTop, .bxf-connectedFooter { align-items: flex-start; flex-direction: column; }
|
||||
.bxf-botActions { width: 100%; justify-content: flex-start; }
|
||||
.bxf-botActions .bxf-button { min-height: 44px; }
|
||||
.bxf-inlineError { grid-template-columns: minmax(0, 1fr); padding: 20px; }
|
||||
.bxf-statusNotice { align-items: flex-start; flex-wrap: wrap; }
|
||||
.bxf-cardBody { padding: 20px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bxf-page *, .bxf-page *::before, .bxf-page *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
`;
|
||||
|
||||
export function installFeishuStyles() {
|
||||
if (typeof document === "undefined") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const existing = document.querySelector(
|
||||
`style[data-plugin-css="${FEISHU_STYLE_ID}"]`,
|
||||
);
|
||||
if (existing) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.dataset.plugin = "@xmanrui/dsh-feishu";
|
||||
style.dataset.pluginCss = FEISHU_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
style.remove();
|
||||
};
|
||||
}
|
||||
156
plugin-src/client/channels/weixin/api.js
Normal file
156
plugin-src/client/channels/weixin/api.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
export const WEIXIN_RPC_CHANNEL = '/weixin';
|
||||
export const WEIXIN_ENDPOINTS = Object.freeze({
|
||||
status: 'connection.status',
|
||||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
submitVerification: 'provision.verify',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
||||
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
|
||||
const PROVISION_STATES = new Set([
|
||||
'starting',
|
||||
'pending',
|
||||
'scanned',
|
||||
'needs_verification',
|
||||
'connecting',
|
||||
'connected',
|
||||
'expired',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function string(value, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function unwrapRpcResult(result) {
|
||||
if (!isRecord(result) || typeof result.ok !== 'boolean') {
|
||||
throw new Error('微信服务返回了无法识别的响应');
|
||||
}
|
||||
if (!result.ok) {
|
||||
const error = new Error(string(result.error?.message, '微信操作失败'));
|
||||
error.code = string(result.error?.code, 'WEIXIN_RPC_ERROR');
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export function safeQrSource(value) {
|
||||
return typeof value === 'string'
|
||||
&& /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function safeVerificationUrl(value) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const host = url.hostname.toLowerCase();
|
||||
return url.protocol === 'https:'
|
||||
&& (host === 'weixin.qq.com' || host.endsWith('.weixin.qq.com'))
|
||||
? url.toString()
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProvisioning(value) {
|
||||
if (!isRecord(value) || !string(value.attemptId)) {
|
||||
throw new Error('微信扫码服务没有返回有效的绑定任务');
|
||||
}
|
||||
const status = PROVISION_STATES.has(value.status) ? value.status : 'failed';
|
||||
const result = {
|
||||
attemptId: string(value.attemptId),
|
||||
status,
|
||||
expiresAt: timestamp(value.expiresAt) ?? Date.now(),
|
||||
pollIntervalMs: Math.min(5_000, Math.max(500, Number(value.pollIntervalMs) || 1_000)),
|
||||
verificationRequired: value.verificationRequired === true || status === 'needs_verification',
|
||||
};
|
||||
const verificationUrl = safeVerificationUrl(value.verificationUrl);
|
||||
const qrCodeDataUrl = safeQrSource(value.qrCodeDataUrl);
|
||||
if (verificationUrl) result.verificationUrl = verificationUrl;
|
||||
if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl;
|
||||
if (string(value.botId)) result.botId = string(value.botId);
|
||||
if (value.alreadyConnected === true) result.alreadyConnected = true;
|
||||
if (isRecord(value.error)) {
|
||||
result.error = {
|
||||
code: string(value.error.code, 'WEIXIN_PROVISION_FAILED'),
|
||||
message: string(value.error.message, '微信绑定没有完成'),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeBot(value) {
|
||||
if (!isRecord(value) || !string(value.botId) || !isRecord(value.bot)) return null;
|
||||
const state = ACCOUNT_STATES.has(value.state) ? value.state : 'error';
|
||||
const connected = value.connected === true;
|
||||
return {
|
||||
botId: string(value.botId),
|
||||
state: connected ? 'connected' : state,
|
||||
connected,
|
||||
configured: value.configured === true,
|
||||
bot: {
|
||||
name: string(value.bot.name, '微信机器人'),
|
||||
accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),
|
||||
},
|
||||
health: {
|
||||
status: string(value.health?.status, connected ? 'healthy' : 'offline'),
|
||||
summary: string(value.health?.summary, connected ? '微信连接正常' : '微信连接未就绪'),
|
||||
lastCheckedAt: timestamp(value.health?.lastCheckedAt),
|
||||
},
|
||||
stats: {
|
||||
messagesReceived: Math.max(0, Number(value.stats?.messagesReceived) || 0),
|
||||
messagesReplied: Math.max(0, Number(value.stats?.messagesReplied) || 0),
|
||||
},
|
||||
error: isRecord(value.error)
|
||||
? {
|
||||
code: string(value.error.code, 'WEIXIN_ACCOUNT_ERROR'),
|
||||
message: string(value.error.message, '微信连接未就绪'),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSnapshot(value) {
|
||||
if (!isRecord(value) || !Array.isArray(value.bots)) {
|
||||
throw new Error('微信服务没有返回有效的账号列表');
|
||||
}
|
||||
const bots = value.bots.map(normalizeBot).filter(Boolean);
|
||||
return {
|
||||
schemaVersion: Number(value.schemaVersion) || 1,
|
||||
revision: Number(value.revision) || 0,
|
||||
state: string(value.state, 'offline'),
|
||||
bots,
|
||||
totals: {
|
||||
configured: bots.length,
|
||||
connected: bots.filter((bot) => bot.connected).length,
|
||||
},
|
||||
provisioning: value.provisioning ? normalizeProvisioning(value.provisioning) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function presentError(error) {
|
||||
return {
|
||||
code: string(error?.code, 'WEIXIN_ERROR'),
|
||||
message: string(error?.message, '微信操作失败,请稍后重试'),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRemaining(milliseconds) {
|
||||
const seconds = Math.max(0, Math.ceil(milliseconds / 1_000));
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
562
plugin-src/client/channels/weixin/index.js
Normal file
562
plugin-src/client/channels/weixin/index.js
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
WEIXIN_ENDPOINTS,
|
||||
WEIXIN_RPC_CHANNEL,
|
||||
formatRemaining,
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
presentError,
|
||||
safeQrSource,
|
||||
safeVerificationUrl,
|
||||
unwrapRpcResult,
|
||||
} from './api.js';
|
||||
import { installWeixinStyles } from './styles.js';
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
export const name = 'weixin-settings';
|
||||
export const inject = ['slots', 'connection'];
|
||||
|
||||
function WeixinIcon({ size = 26 }) {
|
||||
return h('svg', {
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
'aria-hidden': 'true',
|
||||
},
|
||||
h('path', {
|
||||
d: 'M9.7 4.2c-4.15 0-7.5 2.72-7.5 6.08 0 1.91 1.08 3.61 2.78 4.72l-.7 2.35 2.75-1.37c.84.25 1.73.38 2.67.38 4.14 0 7.5-2.72 7.5-6.08S13.84 4.2 9.7 4.2Z',
|
||||
fill: 'currentColor', opacity: '.96',
|
||||
}),
|
||||
h('path', {
|
||||
d: 'M14.4 8.2c4.08 0 7.4 2.66 7.4 5.95 0 1.78-.97 3.38-2.5 4.47l.58 2-2.36-1.18c-.97.43-2.03.65-3.12.65-4.09 0-7.4-2.66-7.4-5.94 0-3.29 3.31-5.95 7.4-5.95Z',
|
||||
fill: 'currentColor', stroke: 'white', strokeWidth: '.8',
|
||||
}),
|
||||
h('circle', { cx: '7.25', cy: '9.5', r: '1', fill: 'white' }),
|
||||
h('circle', { cx: '11.55', cy: '9.5', r: '1', fill: 'white' }),
|
||||
h('circle', { cx: '12.1', cy: '13.2', r: '.9', fill: 'white' }),
|
||||
h('circle', { cx: '16.3', cy: '13.2', r: '.9', fill: 'white' }));
|
||||
}
|
||||
|
||||
const Button = React.forwardRef(function Button(
|
||||
{ children, kind = 'secondary', className = '', ...props },
|
||||
ref,
|
||||
) {
|
||||
return h('button', {
|
||||
...props,
|
||||
ref,
|
||||
type: 'button',
|
||||
className: `dxw-button ${className}`.trim(),
|
||||
'data-kind': kind,
|
||||
}, children);
|
||||
});
|
||||
|
||||
function Heading({ totals, adding, busy, onAdd, addButtonRef }) {
|
||||
return h('div', { className: 'dxw-heading' },
|
||||
h('div', { className: 'dxw-tools' },
|
||||
totals.configured > 0
|
||||
? h('div', { className: 'dxw-badge' },
|
||||
h('span', { className: 'dxw-dot', 'data-tone': totals.connected > 0 ? 'success' : 'warning' }),
|
||||
h('span', null, `${totals.connected} / ${totals.configured} 在线`))
|
||||
: null,
|
||||
h('div', { className: 'dxw-badge', title: 'bot_token 仅保存在 Harness Host 凭据服务中' },
|
||||
h('span', null, '凭据仅保存在本机')),
|
||||
h(Button, {
|
||||
kind: 'primary',
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
}, adding ? '正在绑定' : '扫码绑定微信'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingView() {
|
||||
return h('div', { className: 'dxw-card dxw-loading', 'aria-busy': 'true' },
|
||||
h('div', { className: 'dxw-spinner' }),
|
||||
h('span', null, '正在读取微信连接状态…'));
|
||||
}
|
||||
|
||||
function EmptyView({ onStart, busy }) {
|
||||
return h('div', { className: 'dxw-card' },
|
||||
h('div', { className: 'dxw-cardBody dxw-empty' },
|
||||
h('div', null,
|
||||
h('div', { className: 'dxw-stateLabel' },
|
||||
h('span', { className: 'dxw-dot' }), h('span', null, '尚未绑定微信')),
|
||||
h('h3', null, '扫一次码,就能在微信里使用 Harness'),
|
||||
h('p', null, '二维码由腾讯微信 iLink 服务签发。用手机微信扫描并确认后,账号凭据会直接写入 Harness Host,浏览器不会收到 bot_token。'),
|
||||
h('div', { className: 'dxw-actions' },
|
||||
h(Button, { kind: 'primary', onClick: onStart, disabled: busy },
|
||||
busy ? '正在生成二维码…' : '生成微信二维码')),
|
||||
),
|
||||
h('div', { className: 'dxw-logo', 'aria-hidden': 'true' }, h(WeixinIcon, { size: 64 })),
|
||||
));
|
||||
}
|
||||
|
||||
function QrPanel({ provision, now, busy, onRefresh, onCancel }) {
|
||||
const [imageFailed, setImageFailed] = React.useState(false);
|
||||
const source = safeQrSource(provision.qrCodeDataUrl);
|
||||
const href = safeVerificationUrl(provision.verificationUrl);
|
||||
const remaining = Math.max(0, provision.expiresAt - now);
|
||||
const expired = remaining === 0 || provision.status === 'expired';
|
||||
const duration = Math.max(1, provision.durationMs ?? 5 * 60_000);
|
||||
const progress = Math.round(Math.min(1, remaining / duration) * 100);
|
||||
React.useEffect(() => setImageFailed(false), [source]);
|
||||
|
||||
return h('div', { className: 'dxw-card' },
|
||||
h('div', { className: 'dxw-cardBody dxw-qrLayout' },
|
||||
h('div', { className: 'dxw-qrColumn' },
|
||||
h('div', { className: 'dxw-qrFrame' },
|
||||
source && !imageFailed
|
||||
? h('img', {
|
||||
src: source,
|
||||
alt: '用于把微信机器人绑定到 DeepSeek Harness 的一次性二维码',
|
||||
onError: () => setImageFailed(true),
|
||||
})
|
||||
: h('div', { className: 'dxw-qrFallback' }, '二维码图片未就绪,请使用备用链接。'),
|
||||
expired ? h('div', { className: 'dxw-expired' }, '二维码已过期\n请重新生成') : null,
|
||||
),
|
||||
h('div', { className: 'dxw-countdown' },
|
||||
h('div', null, h('span', null, '二维码有效时间'), h('strong', null, formatRemaining(remaining))),
|
||||
h('div', { className: 'dxw-progress', 'aria-hidden': 'true' },
|
||||
h('span', { style: { '--dxw-progress': `${progress}%` } })),
|
||||
)),
|
||||
h('div', { className: 'dxw-qrCopy' },
|
||||
h('div', { className: 'dxw-stateLabel' },
|
||||
h('span', { className: 'dxw-dot', 'data-tone': provision.status === 'scanned' ? 'success' : 'warning' }),
|
||||
h('span', null, provision.status === 'scanned' ? '已扫码,请在手机上确认' : '等待微信扫码')),
|
||||
h('h3', null, expired ? '二维码已失效' : '使用手机微信扫描二维码'),
|
||||
h('p', null, '请在手机上核对并确认授权。部分账号会额外显示一个配对数字,页面会在需要时提示输入。'),
|
||||
h('ol', { className: 'dxw-steps' },
|
||||
h('li', null, '打开手机微信并扫描左侧二维码'),
|
||||
h('li', null, '在微信中确认连接该机器人'),
|
||||
h('li', null, '保持本页打开,等待消息长轮询变为在线')),
|
||||
h('div', { className: 'dxw-actions' },
|
||||
expired
|
||||
? h(Button, { kind: 'primary', onClick: onRefresh, disabled: busy }, '重新生成二维码')
|
||||
: null,
|
||||
href ? h('a', {
|
||||
className: 'dxw-button', href, target: '_blank', rel: 'noopener noreferrer',
|
||||
}, '打开备用链接') : null,
|
||||
!expired ? h(Button, { onClick: onRefresh, disabled: busy }, '换一个二维码') : null,
|
||||
h(Button, { onClick: onCancel, disabled: busy }, '取消')),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
function VerificationPanel({ provision, busy, onSubmit, onCancel }) {
|
||||
const [code, setCode] = React.useState('');
|
||||
const valid = /^\d{4,8}$/.test(code);
|
||||
React.useEffect(() => setCode(''), [provision.attemptId]);
|
||||
return h('div', { className: 'dxw-card' },
|
||||
h('form', {
|
||||
className: 'dxw-verify',
|
||||
onSubmit: (event) => {
|
||||
event.preventDefault();
|
||||
if (valid && !busy) onSubmit(code);
|
||||
},
|
||||
},
|
||||
h('div', { className: 'dxw-stateLabel' },
|
||||
h('span', { className: 'dxw-dot', 'data-tone': 'warning' }), h('span', null, '需要配对码')),
|
||||
h('h3', null, '输入手机微信显示的数字'),
|
||||
h('p', null, '这是微信附加的安全确认步骤。配对码只用于本次扫码轮询,不会写入配置或日志。'),
|
||||
h('div', { className: 'dxw-codeRow' },
|
||||
h('input', {
|
||||
className: 'dxw-input',
|
||||
value: code,
|
||||
inputMode: 'numeric',
|
||||
autoComplete: 'one-time-code',
|
||||
maxLength: 8,
|
||||
'aria-label': '微信配对码',
|
||||
onChange: (event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 8)),
|
||||
autoFocus: true,
|
||||
}),
|
||||
h('button', {
|
||||
type: 'submit',
|
||||
className: 'dxw-button',
|
||||
'data-kind': 'primary',
|
||||
disabled: !valid || busy,
|
||||
}, busy ? '正在验证…' : '继续连接')),
|
||||
h(Button, { onClick: onCancel, disabled: busy }, '取消绑定')));
|
||||
}
|
||||
|
||||
function ProgressPanel({ scanned, onCancel, busy }) {
|
||||
return h('div', { className: 'dxw-card dxw-loading', 'aria-busy': 'true' },
|
||||
h('div', { className: 'dxw-spinner' }),
|
||||
h('h3', null, scanned ? '微信已确认,正在启动消息连接' : '正在准备微信二维码'),
|
||||
h('p', null, scanned ? '正在保存凭据并验证 Harness 与微信长轮询。' : '正在联系腾讯微信 iLink 服务。'),
|
||||
onCancel ? h('div', { className: 'dxw-actions', style: { justifyContent: 'center', marginTop: 14 } },
|
||||
h(Button, { onClick: onCancel, disabled: busy }, '取消')) : null);
|
||||
}
|
||||
|
||||
function ProvisionError({ provision, busy, onRetry, onClose }) {
|
||||
const error = provision.error ?? { code: 'WEIXIN_PROVISION_FAILED', message: '微信绑定没有完成' };
|
||||
return h('div', { className: 'dxw-card' },
|
||||
h('div', { className: 'dxw-error', role: 'alert' },
|
||||
h('h3', null, provision.status === 'expired' ? '二维码已过期' : '微信没有绑定完成'),
|
||||
h('p', null, error.message),
|
||||
h('span', { className: 'dxw-errorCode' }, error.code),
|
||||
h('div', { className: 'dxw-actions' },
|
||||
h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
|
||||
h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
|
||||
}
|
||||
|
||||
function checkedTime(timestamp) {
|
||||
if (!timestamp) return '尚未检查';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
}).format(new Date(timestamp));
|
||||
} catch {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
const state = busy === 'reconnect' ? 'connecting' : account.state;
|
||||
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
|
||||
return h('article', { className: 'dxw-card', tabIndex: -1, 'data-bot-id': account.botId },
|
||||
h('div', { className: 'dxw-cardBody' },
|
||||
h('div', { className: 'dxw-accountTop' },
|
||||
h('div', { className: 'dxw-accountIdentity' },
|
||||
h('div', { className: 'dxw-avatar', 'aria-hidden': 'true' }, h(WeixinIcon, { size: 27 })),
|
||||
h('div', null, h('h3', null, account.bot.name), h('p', null, account.bot.accountIdMasked))),
|
||||
h('div', { className: 'dxw-health' },
|
||||
h('span', { className: 'dxw-dot', 'data-tone': tone }),
|
||||
h('span', null, account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪'))),
|
||||
h('dl', { className: 'dxw-metrics' },
|
||||
h('div', { className: 'dxw-metric' }, h('dt', null, '消息通道'),
|
||||
h('dd', null, account.connected ? 'iLink 长轮询' : '离线')),
|
||||
h('div', { className: 'dxw-metric' }, h('dt', null, '收到 / 回复'),
|
||||
h('dd', null, `${account.stats.messagesReceived} / ${account.stats.messagesReplied}`)),
|
||||
h('div', { className: 'dxw-metric' }, h('dt', null, '最近检查'),
|
||||
h('dd', null, checkedTime(account.health.lastCheckedAt)))),
|
||||
h('div', { className: 'dxw-accountFooter' },
|
||||
h('div', { className: 'dxw-summary' }, account.error?.message ?? account.health.summary),
|
||||
h('div', { className: 'dxw-actions' },
|
||||
h(Button, { onClick: onReconnect, disabled: Boolean(busy) },
|
||||
busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
h(Button, { kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
|
||||
removing ? h('div', { className: 'dxw-confirm', role: 'alertdialog' },
|
||||
h('strong', null, '从此 Harness 移除这个微信账号?'),
|
||||
h('p', null, '这会停止消息连接,并删除本机保存的 bot_token、账号配置和会话映射。其他微信账号不受影响。'),
|
||||
h('div', { className: 'dxw-actions' },
|
||||
h(Button, { onClick: onCancelRemove, disabled: busy === 'delete' }, '保留账号'),
|
||||
h(Button, { kind: 'danger', onClick: onConfirmRemove, disabled: busy === 'delete' },
|
||||
busy === 'delete' ? '正在移除…' : '确认移除')))
|
||||
: null);
|
||||
}
|
||||
|
||||
function AccountList(props) {
|
||||
return h('section', null,
|
||||
h('div', { className: 'dxw-listHeading' }, h('h3', null, '已接入的微信账号'), h('span', null, `${props.bots.length} 个`)),
|
||||
h('ul', { className: 'dxw-list' }, props.bots.map((account) => h('li', { key: account.botId },
|
||||
h(AccountCard, {
|
||||
account,
|
||||
busy: props.busyByBot[account.botId],
|
||||
removing: props.removeTarget === account.botId,
|
||||
onReconnect: () => props.onReconnect(account),
|
||||
onRequestRemove: () => props.onRequestRemove(account),
|
||||
onConfirmRemove: () => props.onConfirmRemove(account),
|
||||
onCancelRemove: props.onCancelRemove,
|
||||
})))));
|
||||
}
|
||||
|
||||
const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
|
||||
|
||||
export function WeixinSettingsTab({ rpcCall }) {
|
||||
const [model, setModel] = React.useState({
|
||||
phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null,
|
||||
});
|
||||
const [provision, setProvision] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [notice, setNotice] = React.useState('');
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
const addButtonRef = React.useRef(null);
|
||||
|
||||
const announce = React.useCallback((value) => {
|
||||
setNotice('');
|
||||
if (value) window.requestAnimationFrame(() => setNotice(value));
|
||||
}, []);
|
||||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||||
}, [rpcCall]);
|
||||
const loadStatus = React.useCallback(async ({ signal, silent = false } = {}) => {
|
||||
if (!silent) setModel((current) => ({ ...current, phase: 'loading', error: null }));
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal));
|
||||
setModel({
|
||||
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
|
||||
revision: snapshot.revision, error: null,
|
||||
});
|
||||
if (snapshot.provisioning) {
|
||||
setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId
|
||||
? { ...current, ...snapshot.provisioning, durationMs: current?.durationMs ?? 5 * 60_000 }
|
||||
: current);
|
||||
}
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return undefined;
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
phase: silent && current.phase === 'ready' ? 'ready' : 'error',
|
||||
error: presentError(error),
|
||||
}));
|
||||
return undefined;
|
||||
}
|
||||
}, [invoke]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadStatus({ signal: controller.signal });
|
||||
return () => controller.abort();
|
||||
}, [loadStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (model.phase !== 'ready') return undefined;
|
||||
const controller = new AbortController();
|
||||
let running = false;
|
||||
const timer = window.setInterval(async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
await loadStatus({ signal: controller.signal, silent: true });
|
||||
running = false;
|
||||
}, 15_000);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [loadStatus, model.phase]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!provision || !['pending', 'scanned'].includes(provision.status)) return undefined;
|
||||
const timer = window.setInterval(() => 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(WEIXIN_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
|
||||
}
|
||||
setProvision({ status: 'starting' });
|
||||
const started = normalizeProvisioning(await invoke(WEIXIN_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' }));
|
||||
setNow(Date.now());
|
||||
setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) });
|
||||
announce('微信二维码已生成,请使用手机微信扫描。');
|
||||
} catch (error) {
|
||||
setProvision({
|
||||
status: 'failed',
|
||||
error: presentError(error),
|
||||
...(provision?.attemptId ? { attemptId: provision.attemptId } : {}),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [announce, invoke, provision?.attemptId]);
|
||||
|
||||
const cancelProvisioning = React.useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (provision?.attemptId && !['failed', 'expired', 'cancelled'].includes(provision.status)) {
|
||||
await invoke(WEIXIN_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
|
||||
}
|
||||
setProvision(null);
|
||||
announce('已取消微信绑定。');
|
||||
window.requestAnimationFrame(() => addButtonRef.current?.focus());
|
||||
} catch (error) {
|
||||
setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [announce, invoke, provision?.attemptId, provision?.status]);
|
||||
|
||||
const submitVerification = React.useCallback(async (verifyCode) => {
|
||||
if (!provision?.attemptId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const next = normalizeProvisioning(await invoke(WEIXIN_ENDPOINTS.submitVerification, {
|
||||
attemptId: provision.attemptId,
|
||||
verifyCode,
|
||||
}));
|
||||
setProvision((current) => ({ ...current, ...next }));
|
||||
announce('配对码已提交,正在等待微信确认。');
|
||||
} catch (error) {
|
||||
setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [announce, invoke, provision?.attemptId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const attemptId = provision?.attemptId;
|
||||
if (!attemptId || !['pending', 'scanned', 'connecting'].includes(provision.status)) return undefined;
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = normalizeProvisioning(await invoke(
|
||||
WEIXIN_ENDPOINTS.pollProvisioning,
|
||||
{ attemptId },
|
||||
controller.signal,
|
||||
));
|
||||
if (result.status === 'connected') {
|
||||
const snapshot = await loadStatus({ signal: controller.signal, silent: true });
|
||||
const account = snapshot?.bots.find((bot) => bot.botId === result.botId);
|
||||
if (!account?.connected) {
|
||||
setProvision((current) => current?.attemptId === attemptId
|
||||
? { ...current, ...result, status: 'connecting' }
|
||||
: current);
|
||||
timer = window.setTimeout(poll, result.pollIntervalMs);
|
||||
return;
|
||||
}
|
||||
setProvision(null);
|
||||
announce(result.alreadyConnected
|
||||
? '这个微信账号已经绑定并保持在线。'
|
||||
: '微信已绑定,可以开始向已绑定的机器人发消息。');
|
||||
return;
|
||||
}
|
||||
setProvision((current) => current?.attemptId === attemptId
|
||||
? { ...current, ...result, durationMs: current.durationMs }
|
||||
: current);
|
||||
if (['pending', 'scanned', 'connecting'].includes(result.status)) {
|
||||
timer = window.setTimeout(poll, result.pollIntervalMs);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
setProvision((current) => current?.attemptId === attemptId
|
||||
? { ...current, status: 'failed', error: presentError(error) }
|
||||
: current);
|
||||
}
|
||||
};
|
||||
timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]);
|
||||
|
||||
const setBotBusy = React.useCallback((botId, value) => {
|
||||
setBusyByBot((current) => {
|
||||
const next = { ...current };
|
||||
if (value) next[botId] = value;
|
||||
else delete next[botId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reconnect = React.useCallback(async (account) => {
|
||||
setBotBusy(account.botId, 'reconnect');
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId }));
|
||||
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
|
||||
const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
|
||||
announce(refreshed?.connected ? '微信连接检查完成。' : '微信仍未连接,插件会继续自动重试。');
|
||||
} catch (error) {
|
||||
announce(`连接检查失败:${presentError(error).message}`);
|
||||
} finally {
|
||||
setBotBusy(account.botId, null);
|
||||
}
|
||||
}, [announce, invoke, setBotBusy]);
|
||||
|
||||
const remove = React.useCallback(async (account) => {
|
||||
setBotBusy(account.botId, 'delete');
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.deleteBot, {
|
||||
botId: account.botId,
|
||||
confirm: true,
|
||||
}));
|
||||
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
|
||||
setRemoveTarget(null);
|
||||
announce('微信账号及本机凭据已移除。');
|
||||
} catch (error) {
|
||||
announce(`移除失败:${presentError(error).message}`);
|
||||
} finally {
|
||||
setBotBusy(account.botId, null);
|
||||
}
|
||||
}, [announce, invoke, setBotBusy]);
|
||||
|
||||
let provisionView = null;
|
||||
if (provision?.status === 'starting') {
|
||||
provisionView = h(ProgressPanel, { busy });
|
||||
} else if (['pending', 'scanned'].includes(provision?.status)) {
|
||||
provisionView = h(QrPanel, {
|
||||
provision, now, busy,
|
||||
onRefresh: () => void startProvisioning({ replace: true }),
|
||||
onCancel: () => void cancelProvisioning(),
|
||||
});
|
||||
} else if (provision?.status === 'needs_verification') {
|
||||
provisionView = h(VerificationPanel, {
|
||||
provision, busy,
|
||||
onSubmit: (code) => void submitVerification(code),
|
||||
onCancel: () => void cancelProvisioning(),
|
||||
});
|
||||
} else if (provision?.status === 'connecting') {
|
||||
provisionView = h(ProgressPanel, {
|
||||
scanned: true, busy, onCancel: () => void cancelProvisioning(),
|
||||
});
|
||||
} else if (provision && ['failed', 'expired', 'cancelled'].includes(provision.status)) {
|
||||
provisionView = h(ProvisionError, {
|
||||
provision, busy,
|
||||
onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
|
||||
onClose: () => void cancelProvisioning(),
|
||||
});
|
||||
}
|
||||
|
||||
return h('section', { className: 'dxw-page', 'aria-label': '微信设置' },
|
||||
h(Heading, {
|
||||
totals: model.totals,
|
||||
adding: Boolean(provision),
|
||||
busy,
|
||||
onAdd: () => void startProvisioning(),
|
||||
addButtonRef,
|
||||
}),
|
||||
h('div', { className: 'dxw-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice),
|
||||
model.error && model.phase === 'ready'
|
||||
? h('div', { className: 'dxw-statusNotice' }, `状态刷新失败:${model.error.message}`)
|
||||
: null,
|
||||
model.phase === 'loading'
|
||||
? h(LoadingView)
|
||||
: model.phase === 'error'
|
||||
? h('div', { className: 'dxw-card' },
|
||||
h('div', { className: 'dxw-error' },
|
||||
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, { onStart: () => void startProvisioning(), busy })
|
||||
: null,
|
||||
model.bots.length > 0
|
||||
? h(AccountList, {
|
||||
bots: model.bots,
|
||||
busyByBot,
|
||||
removeTarget,
|
||||
onReconnect: (account) => void reconnect(account),
|
||||
onRequestRemove: (account) => setRemoveTarget(account.botId),
|
||||
onConfirmRemove: (account) => void remove(account),
|
||||
onCancelRemove: () => setRemoveTarget(null),
|
||||
})
|
||||
: null),
|
||||
);
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(() => installWeixinStyles(), 'weixin-settings: install client styles');
|
||||
const rpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(WEIXIN_RPC_CHANNEL, endpoint, payload, signal);
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'weixin',
|
||||
order: 30,
|
||||
label: '微信',
|
||||
inject: () => ({ rpcCall }),
|
||||
}, WeixinSettingsTab));
|
||||
}
|
||||
120
plugin-src/client/channels/weixin/styles.js
Normal file
120
plugin-src/client/channels/weixin/styles.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
export const WEIXIN_STYLE_ID = 'xmanrui-dsh-weixin-settings';
|
||||
|
||||
const CSS = String.raw`
|
||||
.dxw-page {
|
||||
--dxw-accent: #07c160;
|
||||
--dxw-accent-dark: #05994c;
|
||||
--dxw-success: var(--dsw-alias-state-success-primary, #20a162);
|
||||
--dxw-warning: var(--dsw-alias-state-warning-primary, #d97706);
|
||||
--dxw-error: var(--dsw-alias-state-error-primary, #d54941);
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 2px 0 28px;
|
||||
color: var(--dsw-alias-label-primary, #1f2329);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dxw-page *, .dxw-page *::before, .dxw-page *::after { box-sizing: border-box; }
|
||||
.dxw-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||
.dxw-heading h2, .dxw-heading p, .dxw-card h3, .dxw-card p { margin: 0; }
|
||||
.dxw-eyebrow { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 3px; }
|
||||
.dxw-heading h2 { font-size: 20px; line-height: 28px; font-weight: 680; }
|
||||
.dxw-heading p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; margin-top: 5px; white-space: nowrap; }
|
||||
.dxw-tools, .dxw-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.dxw-tools { justify-content: flex-end; }
|
||||
.dxw-badge { display: inline-flex; align-items: center; gap: 7px; min-height: 30px; padding: 0 11px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-fill-secondary, #f2f3f5); font-size: 12px; white-space: nowrap; }
|
||||
.dxw-dot { width: 8px; height: 8px; border-radius: 50%; background: #aeb3bb; flex: none; }
|
||||
.dxw-dot[data-tone="success"] { background: var(--dxw-success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dxw-success) 14%, transparent); }
|
||||
.dxw-dot[data-tone="warning"] { background: var(--dxw-warning); }
|
||||
.dxw-dot[data-tone="error"] { background: var(--dxw-error); }
|
||||
.dxw-button { min-height: 34px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; padding: 0 13px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: inherit; font-size: 13px; font-weight: 560; cursor: pointer; text-decoration: none; transition: border-color .15s ease, background .15s ease, transform .15s ease; }
|
||||
.dxw-button:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-fill-tertiary, #f7f8fa); }
|
||||
.dxw-button:active:not(:disabled) { transform: translateY(1px); }
|
||||
.dxw-button:focus-visible, .dxw-input:focus-visible { outline: 2px solid color-mix(in srgb, var(--dxw-accent) 70%, white); outline-offset: 2px; }
|
||||
.dxw-button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.dxw-button[data-kind="primary"] { color: white; border-color: var(--dxw-accent); background: var(--dxw-accent); }
|
||||
.dxw-button[data-kind="primary"]:hover:not(:disabled) { border-color: var(--dxw-accent-dark); background: var(--dxw-accent-dark); }
|
||||
.dxw-button[data-kind="danger"] { color: var(--dxw-error); }
|
||||
.dxw-card { overflow: hidden; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-body, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 3%); }
|
||||
.dxw-cardBody { padding: 24px; }
|
||||
.dxw-empty { min-height: 230px; display: grid; grid-template-columns: minmax(0, 1fr) 180px; align-items: center; gap: 30px; }
|
||||
.dxw-empty h3 { font-size: 18px; margin-bottom: 8px; }
|
||||
.dxw-empty p { max-width: 560px; color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.65; }
|
||||
.dxw-empty .dxw-actions { margin-top: 20px; }
|
||||
.dxw-logo { width: 110px; height: 110px; display: grid; place-items: center; justify-self: center; border-radius: 28px; color: white; background: var(--dxw-accent); box-shadow: 0 18px 45px rgb(7 193 96 / 22%); }
|
||||
.dxw-logo svg { width: 62px; height: 62px; }
|
||||
.dxw-qrLayout { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 34px; align-items: center; }
|
||||
.dxw-qrColumn { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.dxw-qrFrame { position: relative; width: 270px; aspect-ratio: 1; display: grid; place-items: center; overflow: hidden; padding: 10px; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); border-radius: 16px; background: white; }
|
||||
.dxw-qrFrame img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
.dxw-qrFallback { padding: 24px; text-align: center; color: #646a73; }
|
||||
.dxw-expired { position: absolute; inset: 0; display: grid; place-items: center; padding: 30px; color: white; text-align: center; font-weight: 650; background: rgb(31 35 41 / 76%); backdrop-filter: blur(3px); }
|
||||
.dxw-countdown { width: 270px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; }
|
||||
.dxw-countdown div { display: flex; justify-content: space-between; margin-bottom: 6px; }
|
||||
.dxw-progress { height: 4px; overflow: hidden; border-radius: 99px; background: #eef0f3; }
|
||||
.dxw-progress span { display: block; width: var(--dxw-progress); height: 100%; background: var(--dxw-accent); transition: width .2s linear; }
|
||||
.dxw-qrCopy h3 { margin: 9px 0 8px; font-size: 18px; }
|
||||
.dxw-qrCopy > p { color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.65; }
|
||||
.dxw-steps { margin: 18px 0 22px; padding-left: 22px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 1.9; }
|
||||
.dxw-stateLabel { display: inline-flex; align-items: center; gap: 8px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; font-weight: 600; }
|
||||
.dxw-verify { max-width: 560px; margin: 0 auto; padding: 32px; text-align: center; }
|
||||
.dxw-verify h3 { margin: 8px 0; font-size: 19px; }
|
||||
.dxw-verify p { color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.6; }
|
||||
.dxw-codeRow { display: flex; justify-content: center; gap: 10px; margin: 24px 0 10px; }
|
||||
.dxw-input { width: 190px; height: 42px; padding: 0 14px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 9px; background: var(--dsw-alias-bg-body, white); color: inherit; font: inherit; font-size: 18px; letter-spacing: .16em; text-align: center; }
|
||||
.dxw-statusNotice, .dxw-error { display: flex; align-items: center; gap: 10px; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--dxw-error) 28%, transparent); border-radius: 10px; color: var(--dxw-error); background: color-mix(in srgb, var(--dxw-error) 7%, transparent); font-size: 13px; }
|
||||
.dxw-error { align-items: flex-start; flex-direction: column; padding: 22px; }
|
||||
.dxw-error h3 { font-size: 17px; }
|
||||
.dxw-errorCode { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11px; opacity: .8; }
|
||||
.dxw-listHeading { display: flex; justify-content: space-between; align-items: center; margin: 2px 0 9px; }
|
||||
.dxw-listHeading h3 { margin: 0; font-size: 14px; }
|
||||
.dxw-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; }
|
||||
.dxw-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||
.dxw-accountTop { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.dxw-accountIdentity { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.dxw-avatar { width: 42px; height: 42px; display: grid; place-items: center; flex: none; border-radius: 12px; color: white; background: var(--dxw-accent); }
|
||||
.dxw-accountIdentity h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
|
||||
.dxw-accountIdentity p { color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; margin-top: 4px; }
|
||||
.dxw-health { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; }
|
||||
.dxw-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 20px 0; }
|
||||
.dxw-metric { padding: 12px 14px; border-radius: 9px; background: var(--dsw-alias-fill-tertiary, #f7f8fa); }
|
||||
.dxw-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; }
|
||||
.dxw-metric dd { overflow: hidden; margin: 5px 0 0; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dxw-accountFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-line-divider, #eef0f3); }
|
||||
.dxw-summary { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; }
|
||||
.dxw-confirm { padding: 18px 24px; border-top: 1px solid color-mix(in srgb, var(--dxw-error) 25%, transparent); background: color-mix(in srgb, var(--dxw-error) 5%, transparent); }
|
||||
.dxw-confirm strong { display: block; font-size: 14px; margin-bottom: 6px; }
|
||||
.dxw-confirm p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.55; }
|
||||
.dxw-confirm .dxw-actions { margin-top: 13px; }
|
||||
.dxw-loading { padding: 36px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; }
|
||||
.dxw-spinner { width: 24px; height: 24px; margin: 0 auto 12px; border: 3px solid #e6e8eb; border-top-color: var(--dxw-accent); border-radius: 50%; animation: dxw-spin .8s linear infinite; }
|
||||
.dxw-visuallyHidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
@keyframes dxw-spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 720px) {
|
||||
.dxw-heading, .dxw-accountTop, .dxw-accountFooter { flex-direction: column; align-items: stretch; }
|
||||
.dxw-tools { justify-content: flex-start; }
|
||||
.dxw-empty { grid-template-columns: minmax(0, 1fr); }
|
||||
.dxw-logo { display: none; }
|
||||
.dxw-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; }
|
||||
.dxw-qrCopy { width: 100%; }
|
||||
.dxw-metrics { grid-template-columns: minmax(0, 1fr); }
|
||||
.dxw-cardBody { padding: 20px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dxw-page *, .dxw-page *::before, .dxw-page *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
`;
|
||||
|
||||
export function installWeixinStyles() {
|
||||
if (typeof document === 'undefined') return () => {};
|
||||
const existing = document.querySelector(`style[data-plugin-css="${WEIXIN_STYLE_ID}"]`);
|
||||
if (existing) return () => {};
|
||||
const style = document.createElement('style');
|
||||
style.dataset.plugin = '@xmanrui/dsh-weixin';
|
||||
style.dataset.pluginCss = WEIXIN_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
103
plugin-src/client/index.js
Normal file
103
plugin-src/client/index.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { FeishuSettingsTab } from './channels/feishu/index.js';
|
||||
import { FEISHU_RPC_CHANNEL } from './channels/feishu/api.js';
|
||||
import { installFeishuStyles } from './channels/feishu/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';
|
||||
import { installImStyles } from './styles.js';
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
export const name = 'im-settings';
|
||||
export const inject = ['slots', 'connection'];
|
||||
|
||||
const CHANNELS = Object.freeze([
|
||||
{ id: 'weixin', label: '微信', description: '扫码绑定' },
|
||||
{ id: 'feishu', label: '飞书', description: '扫码接入' },
|
||||
]);
|
||||
|
||||
function WeixinLogo() {
|
||||
return h('span', { className: 'dim-logo dim-logoWeixin', 'aria-hidden': 'true' },
|
||||
h('svg', { viewBox: '0 0 24 24', focusable: 'false' },
|
||||
h('path', {
|
||||
fill: 'currentColor',
|
||||
d: 'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z',
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
function FeishuLogo() {
|
||||
return h('span', { className: 'dim-logo dim-logoFeishu', 'aria-hidden': 'true' },
|
||||
h('svg', { viewBox: '0 0 24 24', focusable: 'false' },
|
||||
h('path', { fill: '#00D6B9', d: 'M7.2 4.5h7.6c1.2 0 2.1.55 2.7 1.58 1.05 1.8 1.55 3.45 1.58 4.95-2.04-.62-4.2-.15-6.22 1.45C11.3 9.7 9.42 7.04 7.2 4.5Z' }),
|
||||
h('path', { fill: '#1456B8', d: 'M10.8 13.55c3.3-2.93 5.72-4.24 9.47-2.52-1.2 1.45-2.27 4.18-3.86 5.43-1.67 1.31-3.9.5-5.61-.64v-2.27Z' }),
|
||||
h('path', { fill: '#3370FF', d: 'M4.4 8.35c3.47 3.61 7.25 6.1 10.33 5.7 1.06-.14 2.2-.72 3.4-1.72-1.04 2.65-2.6 4.8-5.06 6-2.46 1.2-5.56.52-7.42-.72A2.76 2.76 0 0 1 4.4 15.3V8.35Z' }),
|
||||
));
|
||||
}
|
||||
|
||||
function ChannelLogo({ channel }) {
|
||||
return channel === 'weixin' ? h(WeixinLogo) : h(FeishuLogo);
|
||||
}
|
||||
|
||||
export function IMSettingsTab({ feishuRpcCall, 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机器人设置' },
|
||||
h('header', { className: 'dim-title' },
|
||||
h('p', null, '通过扫码把机器人接入 DeepSeek Harness'),
|
||||
h('div', { className: 'dim-channelCount' }, '2 个渠道'),
|
||||
),
|
||||
h('div', { className: 'dim-layout' },
|
||||
h('nav', { className: 'dim-rail', role: 'tablist', 'aria-label': 'IM 渠道' },
|
||||
CHANNELS.map((channel) => h('button', {
|
||||
key: channel.id,
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
id: `dim-tab-${channel.id}`,
|
||||
className: 'dim-channel',
|
||||
'aria-selected': channel.id === active.id,
|
||||
'aria-controls': `dim-panel-${channel.id}`,
|
||||
onClick: () => setSelected(channel.id),
|
||||
},
|
||||
h(ChannelLogo, { channel: channel.id }),
|
||||
h('span', { className: 'dim-channelCopy' },
|
||||
h('strong', null, channel.label),
|
||||
h('small', null, channel.description),
|
||||
),
|
||||
h('span', { className: 'dim-chevron', 'aria-hidden': 'true' }, '›')))),
|
||||
h('div', { className: 'dim-divider', 'aria-hidden': 'true' }),
|
||||
h('main', {
|
||||
className: 'dim-panel',
|
||||
role: 'tabpanel',
|
||||
id: `dim-panel-${active.id}`,
|
||||
'aria-labelledby': `dim-tab-${active.id}`,
|
||||
}, active.id === 'weixin'
|
||||
? h(WeixinSettingsTab, { rpcCall: weixinRpcCall })
|
||||
: h(FeishuSettingsTab, { rpcCall: feishuRpcCall })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.effect(() => {
|
||||
const disposers = [installFeishuStyles(), installWeixinStyles(), installImStyles()];
|
||||
return () => {
|
||||
for (const dispose of disposers.reverse()) dispose();
|
||||
};
|
||||
}, 'im-settings: install combined channel styles');
|
||||
|
||||
const feishuRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(FEISHU_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const weixinRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(WEIXIN_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: () => ({ feishuRpcCall, weixinRpcCall }),
|
||||
}, IMSettingsTab));
|
||||
}
|
||||
66
plugin-src/client/styles.js
Normal file
66
plugin-src/client/styles.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
export const IM_STYLE_ID = 'xmanrui-dsh-im-settings';
|
||||
|
||||
const CSS = String.raw`
|
||||
.dim-page {
|
||||
--dim-blue: #3370ff;
|
||||
--dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent);
|
||||
width: 100%;
|
||||
max-width: 1080px;
|
||||
padding: 2px 0 30px;
|
||||
color: var(--dsw-alias-label-primary, #1f2329);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dim-page *, .dim-page *::before, .dim-page *::after { box-sizing: border-box; }
|
||||
.dim-title { display: flex; align-items: center; justify-content: space-between; gap: 22px; margin: 0 0 26px; }
|
||||
.dim-title p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; white-space: nowrap; }
|
||||
.dim-channelCount { flex: none; padding: 6px 11px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-fill-secondary, #f2f3f5); font-size: 11px; white-space: nowrap; }
|
||||
.dim-layout { display: grid; grid-template-columns: 188px 1px minmax(0, 1fr); gap: 26px; align-items: start; }
|
||||
.dim-rail { display: grid; gap: 12px; }
|
||||
.dim-channel { width: 100%; min-height: 76px; display: grid; grid-template-columns: 42px minmax(0, 1fr) 14px; align-items: center; gap: 12px; padding: 13px 12px; border: 1px solid transparent; border-radius: 16px; color: inherit; background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 5px 18px rgb(31 35 41 / 5%); font: inherit; text-align: left; cursor: pointer; transition: border-color .16s ease, background .16s ease, box-shadow .16s ease, transform .16s ease; }
|
||||
.dim-channel:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--dim-blue) 25%, transparent); box-shadow: 0 8px 24px rgb(31 35 41 / 8%); }
|
||||
.dim-channel[aria-selected="true"] { border-color: color-mix(in srgb, var(--dim-blue) 52%, transparent); color: var(--dim-blue); background: var(--dim-blue-soft); box-shadow: 0 0 0 1px color-mix(in srgb, var(--dim-blue) 8%, transparent), 0 10px 26px rgb(51 112 255 / 10%); }
|
||||
.dim-channel:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 68%, white); outline-offset: 2px; }
|
||||
.dim-logo { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 12px; box-shadow: 0 1px 4px rgb(31 35 41 / 8%); }
|
||||
.dim-logo svg { display: block; width: 27px; height: 27px; }
|
||||
.dim-logoWeixin { color: white; background: #07c160; }
|
||||
.dim-logoWeixin svg { width: 25px; height: 25px; }
|
||||
.dim-logoFeishu { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); }
|
||||
.dim-logoFeishu svg { width: 30px; height: 30px; }
|
||||
.dim-channelCopy { min-width: 0; display: grid; gap: 2px; }
|
||||
.dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 15px; line-height: 21px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-channelCopy small { overflow: hidden; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-chevron { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 22px; line-height: 1; transform: translateY(-1px); }
|
||||
.dim-channel[aria-selected="true"] .dim-chevron { color: var(--dim-blue); }
|
||||
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-line-divider, #eef0f3); }
|
||||
.dim-panel { min-width: 0; }
|
||||
.dim-panel .bxf-page, .dim-panel .dxw-page { width: 100%; max-width: none; padding: 0 0 24px; }
|
||||
.dim-panel .bxf-heading, .dim-panel .dxw-heading { justify-content: flex-end; }
|
||||
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools { width: 100%; justify-content: flex-end; }
|
||||
@media (max-width: 840px) {
|
||||
.dim-title { align-items: flex-start; }
|
||||
.dim-layout { grid-template-columns: minmax(0, 1fr); gap: 18px; }
|
||||
.dim-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dim-divider { display: none; }
|
||||
.dim-channel { min-height: 68px; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.dim-title { flex-direction: column; gap: 10px; }
|
||||
.dim-title p { white-space: normal; }
|
||||
.dim-rail { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dim-page * { transition-duration: .01ms !important; }
|
||||
}
|
||||
`;
|
||||
|
||||
export function installImStyles() {
|
||||
if (typeof document === 'undefined') return () => {};
|
||||
const existing = document.querySelector(`style[data-plugin-css="${IM_STYLE_ID}"]`);
|
||||
if (existing) return () => {};
|
||||
const style = document.createElement('style');
|
||||
style.dataset.plugin = '@xmanrui/dsh-im';
|
||||
style.dataset.pluginCss = IM_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
24
plugin-src/host/build.mjs
Normal file
24
plugin-src/host/build.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { mkdir } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { build } from 'esbuild';
|
||||
|
||||
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = resolve(sourceDirectory, '../..');
|
||||
const outputPath = resolve(packageRoot, 'lib/index.js');
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await build({
|
||||
entryPoints: [resolve(sourceDirectory, 'index.mjs')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: ['node22'],
|
||||
packages: 'external',
|
||||
outfile: outputPath,
|
||||
sourcemap: false,
|
||||
legalComments: 'none',
|
||||
});
|
||||
|
||||
console.log(`Wrote ${outputPath}`);
|
||||
22
plugin-src/host/index.mjs
Normal file
22
plugin-src/host/index.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { apply as applyFeishu } from '@xmanrui/dsh-feishu';
|
||||
import { apply as applyWeixin } from '@xmanrui/dsh-weixin';
|
||||
|
||||
export const name = 'dsh-im-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
|
||||
export function createImHostPlugin(internals = {}) {
|
||||
const startFeishu = internals.applyFeishu ?? applyFeishu;
|
||||
const startWeixin = internals.applyWeixin ?? applyWeixin;
|
||||
return Object.freeze({
|
||||
name,
|
||||
inject,
|
||||
async apply(ctx, config = {}) {
|
||||
await startFeishu(ctx, config.feishu ?? {});
|
||||
await startWeixin(ctx, config.weixin ?? {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
return createImHostPlugin().apply(ctx, config);
|
||||
}
|
||||
53
scripts/verify-package.mjs
Normal file
53
scripts/verify-package.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { access, readFile, stat } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const required = [
|
||||
'lib/index.js',
|
||||
'lib/client.js',
|
||||
'bin/dsh-im.mjs',
|
||||
'cordis.patch.yml',
|
||||
'README.md',
|
||||
'THIRD_PARTY_NOTICES.md',
|
||||
];
|
||||
await Promise.all(required.map((path) => access(resolve(root, path))));
|
||||
|
||||
const [client, host, patch, manifestText, executable] = await Promise.all([
|
||||
readFile(resolve(root, 'lib/client.js'), 'utf8'),
|
||||
readFile(resolve(root, 'lib/index.js'), 'utf8'),
|
||||
readFile(resolve(root, 'cordis.patch.yml'), 'utf8'),
|
||||
readFile(resolve(root, 'package.json'), 'utf8'),
|
||||
stat(resolve(root, 'bin/dsh-im.mjs')),
|
||||
]);
|
||||
const manifest = JSON.parse(manifestText);
|
||||
|
||||
if (!client.includes('id: "@xmanrui/dsh-im"')) {
|
||||
throw new Error('client bundle does not register the dsh-im loader id');
|
||||
}
|
||||
if (!client.includes('id: "im"') || !client.includes('label: "IM\\u673A\\u5668\\u4EBA"')) {
|
||||
throw new Error('client bundle does not register the IM机器人 settings tab');
|
||||
}
|
||||
if ((client.match(/ctx\.slots\.inject\("settings\.plugins\.tab"/g) ?? []).length !== 1) {
|
||||
throw new Error('client bundle must register exactly one settings tab');
|
||||
}
|
||||
if (/role:\s*["']switch|type:\s*["']checkbox/.test(client)) {
|
||||
throw new Error('client bundle contains a channel enable switch');
|
||||
}
|
||||
if (!host.includes('@xmanrui/dsh-feishu') || !host.includes('@xmanrui/dsh-weixin')) {
|
||||
throw new Error('host bundle does not compose both channel providers');
|
||||
}
|
||||
if (!patch.includes("name: '@xmanrui/dsh-im'") || /dsh-(?:feishu|weixin)/.test(patch)) {
|
||||
throw new Error('bundle patch must activate only dsh-im');
|
||||
}
|
||||
for (const name of ['@xmanrui/dsh-feishu', '@xmanrui/dsh-weixin']) {
|
||||
const spec = manifest.dependencies?.[name];
|
||||
if (typeof spec !== 'string' || !spec.startsWith('https://github.com/')) {
|
||||
throw new Error(`${name} must use a pinned public HTTPS archive`);
|
||||
}
|
||||
}
|
||||
if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable');
|
||||
if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) {
|
||||
throw new Error('built artifacts contain a test or environment secret marker');
|
||||
}
|
||||
|
||||
console.log('Verified dsh-im package artifacts.');
|
||||
25
test/client-ui.test.mjs
Normal file
25
test/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { IMSettingsTab } from '../plugin-src/client/index.js';
|
||||
|
||||
test('IM settings renders two logo channel tabs without enable switches', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
weixinRpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
assert.match(markup, /IM机器人/);
|
||||
assert.match(markup, /通过扫码把机器人接入 DeepSeek Harness/);
|
||||
assert.match(markup, />微信</);
|
||||
assert.match(markup, />飞书</);
|
||||
assert.match(markup, /dim-logoWeixin/);
|
||||
assert.match(markup, /dim-logoFeishu/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 2);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
assert.doesNotMatch(markup, />INSTANT MESSAGING<|>Channel<|>微信设置</);
|
||||
});
|
||||
194
test/feishu-client-api.test.mjs
Normal file
194
test/feishu-client-api.test.mjs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
FEISHU_ENDPOINTS,
|
||||
normalizeBotsSnapshot,
|
||||
normalizeConnectionSnapshot,
|
||||
normalizePollResult,
|
||||
normalizeProvisioning,
|
||||
presentError,
|
||||
reconnectBot,
|
||||
retryConnection,
|
||||
screenFromSnapshot,
|
||||
unwrapRpcResult,
|
||||
} from '../plugin-src/client/channels/feishu/api.js';
|
||||
|
||||
test('multi-bot endpoints are bot-scoped and keep legacy operations separate', () => {
|
||||
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
||||
assert.equal(FEISHU_ENDPOINTS.testConnection, 'connection.test');
|
||||
});
|
||||
|
||||
test('client normalizes multiple independent bots and derives authoritative totals', () => {
|
||||
const snapshot = normalizeBotsSnapshot({
|
||||
schemaVersion: 2,
|
||||
revision: 9,
|
||||
totals: { configured: 99, connected: 99 },
|
||||
bots: [
|
||||
{
|
||||
botId: 'bot-a',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
bot: {
|
||||
name: '销售助手',
|
||||
appIdMasked: 'cli_aaaa••••1111',
|
||||
domain: 'feishu',
|
||||
clientSecret: 'must-not-leak',
|
||||
},
|
||||
health: { status: 'healthy', summary: '长连接运行正常' },
|
||||
},
|
||||
{
|
||||
botId: 'bot-b',
|
||||
// A stale state label must not make an offline bot appear connected.
|
||||
state: 'connected',
|
||||
connected: false,
|
||||
configured: true,
|
||||
bot: { name: '研发助手', domain: 'lark' },
|
||||
health: { status: 'offline', summary: '等待重连' },
|
||||
error: { code: 'connection_failed', message: '连接失败' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(snapshot.schemaVersion, 2);
|
||||
assert.equal(snapshot.revision, 9);
|
||||
assert.deepEqual(snapshot.totals, { configured: 2, connected: 1 });
|
||||
assert.equal(snapshot.bots[0].state, 'connected');
|
||||
assert.equal(snapshot.bots[1].state, 'connecting');
|
||||
assert.equal(snapshot.bots[1].bot.domain, 'lark');
|
||||
assert.equal(snapshot.bots[1].error.message, '连接失败');
|
||||
assert.equal('clientSecret' in snapshot.bots[0].bot, false);
|
||||
});
|
||||
|
||||
test('multi-bot snapshot rejects entries without an opaque botId', () => {
|
||||
assert.throws(
|
||||
() => normalizeBotsSnapshot({ schemaVersion: 2, bots: [{ connected: true }] }),
|
||||
/botId/,
|
||||
);
|
||||
});
|
||||
|
||||
test('provision polling preserves the newly connected botId', () => {
|
||||
assert.deepEqual(normalizePollResult({
|
||||
status: 'connected',
|
||||
botId: 'bot-new',
|
||||
}), {
|
||||
status: 'connected',
|
||||
botId: 'bot-new',
|
||||
message: undefined,
|
||||
connection: undefined,
|
||||
provisioning: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('reconnect helper scopes the mutation to one bot before refreshing the list', async () => {
|
||||
const calls = [];
|
||||
const status = { schemaVersion: 2, bots: [] };
|
||||
const value = await reconnectBot(async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
return endpoint === 'connection.status' ? status : { accepted: true };
|
||||
}, 'bot-a');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ endpoint: 'bot.reconnect', payload: { botId: 'bot-a' } },
|
||||
{ endpoint: 'connection.status', payload: {} },
|
||||
]);
|
||||
assert.equal(value, status);
|
||||
});
|
||||
|
||||
test('client only shows connected when the Host confirms connected=true', () => {
|
||||
assert.equal(normalizeConnectionSnapshot({
|
||||
state: 'connected',
|
||||
connected: false,
|
||||
}).state, 'connecting');
|
||||
assert.equal(normalizeConnectionSnapshot({
|
||||
state: 'connecting',
|
||||
connected: true,
|
||||
}).state, 'connected');
|
||||
});
|
||||
|
||||
test('client accepts a Host-rendered QR code without exposing credentials', () => {
|
||||
const provisioning = normalizeProvisioning({
|
||||
attemptId: '7',
|
||||
verificationUrl: 'https://accounts.feishu.cn/device',
|
||||
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||
expiresAt: 100_000,
|
||||
pollIntervalMs: 1_800,
|
||||
}, 1_000);
|
||||
|
||||
assert.equal(provisioning.attemptId, '7');
|
||||
assert.equal(provisioning.qrCodeDataUrl, 'data:image/png;base64,AAAA');
|
||||
assert.equal('clientSecret' in provisioning, false);
|
||||
});
|
||||
|
||||
test('client unwraps RpcResult and redacts credential-shaped error text', () => {
|
||||
assert.deepEqual(unwrapRpcResult({ ok: true, value: { connected: true } }), {
|
||||
connected: true,
|
||||
});
|
||||
assert.throws(
|
||||
() => unwrapRpcResult({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'failed' },
|
||||
}),
|
||||
/failed/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
presentError(new Error('app_secret=do-not-show token=also-hidden')).message,
|
||||
/do-not-show|also-hidden/,
|
||||
);
|
||||
});
|
||||
|
||||
test('configured offline and startup errors never become the create screen', () => {
|
||||
const offline = normalizeConnectionSnapshot({
|
||||
state: 'disconnected',
|
||||
connected: false,
|
||||
configured: true,
|
||||
bot: { name: '北汇星河助手' },
|
||||
health: { status: 'offline', summary: '长连接已断开' },
|
||||
});
|
||||
assert.equal(offline.configured, true);
|
||||
assert.equal(screenFromSnapshot(offline).phase, 'offline');
|
||||
|
||||
const failed = screenFromSnapshot(normalizeConnectionSnapshot({
|
||||
state: 'error',
|
||||
connected: false,
|
||||
configured: true,
|
||||
error: { message: '启动失败' },
|
||||
}));
|
||||
assert.equal(failed.phase, 'error');
|
||||
assert.equal(failed.retry, 'test');
|
||||
assert.equal(failed.configured, true);
|
||||
|
||||
const failedBeforeConfiguration = screenFromSnapshot(normalizeConnectionSnapshot({
|
||||
state: 'error',
|
||||
connected: false,
|
||||
configured: false,
|
||||
error: { message: '二维码已过期' },
|
||||
}));
|
||||
assert.equal(failedBeforeConfiguration.phase, 'error');
|
||||
assert.equal(failedBeforeConfiguration.retry, 'begin');
|
||||
|
||||
const fresh = screenFromSnapshot(normalizeConnectionSnapshot({
|
||||
state: 'disconnected',
|
||||
connected: false,
|
||||
configured: false,
|
||||
}));
|
||||
assert.deepEqual(fresh, { phase: 'disconnected', configured: false });
|
||||
});
|
||||
|
||||
test('retry connection calls connection.test before authoritative connection.status', async () => {
|
||||
const calls = [];
|
||||
const status = { state: 'connected', connected: true, configured: true };
|
||||
const value = await retryConnection(async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
return endpoint === 'connection.status' ? status : { accepted: true };
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ endpoint: 'connection.test', payload: {} },
|
||||
{ endpoint: 'connection.status', payload: {} },
|
||||
]);
|
||||
assert.equal(value, status);
|
||||
});
|
||||
34
test/host.test.mjs
Normal file
34
test/host.test.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes Feishu and Weixin 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]),
|
||||
});
|
||||
const ctx = { marker: 'shared-context' };
|
||||
const config = { feishu: { domain: 'feishu' }, weixin: { timeout: 30 } };
|
||||
|
||||
await plugin.apply(ctx, config);
|
||||
|
||||
assert.equal(name, 'dsh-im-host');
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer']);
|
||||
assert.deepEqual(calls, [
|
||||
['feishu', ctx, config.feishu],
|
||||
['weixin', ctx, config.weixin],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Host does not start Weixin when Feishu activation fails', async () => {
|
||||
let weixinStarted = false;
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async () => { throw new Error('feishu unavailable'); },
|
||||
applyWeixin: async () => { weixinStarted = true; },
|
||||
});
|
||||
|
||||
await assert.rejects(() => plugin.apply({}, {}), /feishu unavailable/);
|
||||
assert.equal(weixinStarted, false);
|
||||
});
|
||||
55
test/weixin-client-api.test.mjs
Normal file
55
test/weixin-client-api.test.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
safeQrSource,
|
||||
safeVerificationUrl,
|
||||
unwrapRpcResult,
|
||||
} from '../plugin-src/client/channels/weixin/api.js';
|
||||
|
||||
test('client normalizes the exact redacted Host account view', () => {
|
||||
const snapshot = normalizeSnapshot({
|
||||
schemaVersion: 1,
|
||||
revision: 5,
|
||||
bots: [{
|
||||
botId: 'wx_safe',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
bot: { name: '微信机器人', accountIdMasked: 'abc••••bot' },
|
||||
health: { status: 'healthy', summary: '正常', lastCheckedAt: 123 },
|
||||
stats: { messagesReceived: 3, messagesReplied: 2 },
|
||||
token: 'host-secret-that-must-be-dropped',
|
||||
}],
|
||||
});
|
||||
assert.equal(snapshot.totals.connected, 1);
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /host-secret|token/);
|
||||
});
|
||||
|
||||
test('client accepts only image data URLs and Tencent Weixin HTTPS links', () => {
|
||||
assert.match(safeQrSource('data:image/png;base64,AAAA'), /^data:image/);
|
||||
assert.equal(safeQrSource('javascript:alert(1)'), undefined);
|
||||
assert.equal(safeVerificationUrl('https://liteapp.weixin.qq.com/q/test'), 'https://liteapp.weixin.qq.com/q/test');
|
||||
assert.equal(safeVerificationUrl('https://attacker.test/q/test'), undefined);
|
||||
});
|
||||
|
||||
test('client preserves verification-required provisioning without accepting unknown states', () => {
|
||||
const value = normalizeProvisioning({
|
||||
attemptId: 'attempt',
|
||||
status: 'needs_verification',
|
||||
expiresAt: Date.now() + 1000,
|
||||
verificationRequired: true,
|
||||
});
|
||||
assert.equal(value.status, 'needs_verification');
|
||||
assert.equal(value.verificationRequired, true);
|
||||
assert.equal(normalizeProvisioning({ attemptId: 'attempt', status: 'mystery' }).status, 'failed');
|
||||
});
|
||||
|
||||
test('RPC errors are surfaced with their safe code', () => {
|
||||
assert.throws(
|
||||
() => unwrapRpcResult({ ok: false, error: { code: 'safe-code', message: '安全消息' } }),
|
||||
(error) => error.code === 'safe-code' && error.message === '安全消息',
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue