mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
Add manual IM bot credential access
This commit is contained in:
parent
d5d2c18ce0
commit
7123fce47a
42 changed files with 2458 additions and 889 deletions
48
README.md
48
README.md
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
## 中文
|
||||
|
||||
通过扫码把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信和 QQ 机器人。
|
||||
通过扫码或已有应用凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信和 QQ 机器人。
|
||||
|
||||
> GitHub 简介:通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。
|
||||
> GitHub 简介:通过扫码或应用凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。
|
||||
|
||||
## 界面
|
||||
|
||||
|
|
@ -12,11 +12,11 @@
|
|||
|
||||
## 当前内置渠道
|
||||
|
||||
- 飞书:扫码创建并绑定机器人,使用长连接收发消息;
|
||||
- 飞书:扫码创建机器人,或使用已有 App ID + App Secret 绑定机器人,使用长连接收发消息;
|
||||
- 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息;
|
||||
- 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。
|
||||
- 企业微信:使用企业微信 App 扫码创建并授权智能机器人,通过官方 WebSocket 长连接收消息,原生显示“正在思考中”、工具执行进度和流式回答。
|
||||
- QQ:使用手机 QQ 扫码创建或绑定 QQ 机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。
|
||||
- 钉钉:扫码创建机器人,或使用已有 Client ID + Client Secret 绑定机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。
|
||||
- 企业微信:使用企业微信 App 扫码创建智能机器人,或使用已有 Bot ID + Secret 绑定机器人,通过官方 WebSocket 长连接收消息,原生显示“正在思考中”、工具执行进度和流式回答。
|
||||
- QQ:使用手机 QQ 扫码创建机器人,或使用已有 AppID + AppSecret 绑定机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。
|
||||
|
||||
其他 IM 平台可继续按同一渠道适配器结构接入。
|
||||
|
||||
|
|
@ -28,11 +28,15 @@ npx -y github:xmanrui/dsh-im install
|
|||
|
||||
重启 `dsh web`,然后打开「设置 → 插件 → IM机器人」。安装器会用 `dsh-im` 替换 profile 中直接安装的 `dsh-feishu`、`dsh-weixin` 和 `dsh-dingtalk`,但不删除任何渠道数据;原有渠道凭据和扫码绑定会继续使用。
|
||||
|
||||
钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。
|
||||
飞书、QQ、钉钉和企业微信页面都提供两种入口:带二维码图标的蓝色「扫码接入机器人」按钮走平台官方扫码流程,右侧带钥匙图标的白色描边「手动接入」按钮连接已经创建的机器人应用。飞书和 QQ 分别填写 App ID + App Secret、AppID + AppSecret;钉钉填写官方 Client ID + Client Secret;企业微信填写官方 Bot ID + Secret。Secret 只提交给本机 Harness Host,并写入受保护的凭据存储;状态接口和机器人列表不会回传 Secret。
|
||||
|
||||
企业微信接入使用企业微信官方扫码流程,不需要手动填写 Bot ID 或 Secret。请使用已加入企业且具有机器人创建或管理权限的企业微信账号扫码,并在手机端确认创建智能机器人。扫码创建的是企业微信智能机器人,不是让插件直接登录个人微信账号。插件不设置本机二次批准流程,企业微信中的机器人可见范围就是入站访问范围,请只开放给信任的企业成员和群聊。
|
||||
钉钉扫码接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。
|
||||
|
||||
QQ 接入使用腾讯 QQBot v2 官方扫码流程,不需要手动填写 AppID 或 AppSecret。默认腾讯授权页会把接入方显示为“第三方机器人”;扫码成功后创建或绑定的是 QQ 开放平台机器人,并不是让插件直接控制个人 QQ 账号。扫码者会自动成为该机器人在 Harness 中的使用者,不增加本机二次批准步骤。
|
||||
企业微信扫码接入时,请使用已加入企业且具有机器人创建或管理权限的企业微信账号,并在手机端确认创建智能机器人。扫码创建的是企业微信智能机器人,不是让插件直接登录个人微信账号。无论扫码还是凭据绑定,企业微信中的机器人可见范围就是入站访问范围,请只开放给信任的企业成员和群聊。
|
||||
|
||||
QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把接入方显示为“第三方机器人”;扫码成功后创建的是 QQ 开放平台机器人,并不是让插件直接控制个人 QQ 账号。扫码绑定只接受扫码者的消息;手动凭据无法识别扫码人,因此使用 QQ 开放平台中的机器人可见范围作为入站访问范围。
|
||||
|
||||
飞书扫码绑定会把扫码者作为允许使用者;手动凭据同样无法识别扫码人,因此使用飞书应用的可见范围作为入站访问范围。请在飞书开放平台中只向信任的租户、群或成员开放应用。
|
||||
|
||||
## 设计
|
||||
|
||||
|
|
@ -40,7 +44,7 @@ QQ 接入使用腾讯 QQBot v2 官方扫码流程,不需要手动填写 AppID
|
|||
- 飞书、微信、钉钉、企业微信和 QQ 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
|
||||
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信和 QQ,不使用启用/停用开关;
|
||||
- 五个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
||||
- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret` 或原始用户标识。
|
||||
- 浏览器只获得二维码和脱敏状态;手动输入的 Secret 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret` 或原始用户标识。
|
||||
|
||||
## 本地开发
|
||||
|
||||
|
|
@ -56,9 +60,9 @@ node bin/dsh-im.mjs install --source .
|
|||
|
||||
## English
|
||||
|
||||
Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, and QQ bots.
|
||||
Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing application credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, and QQ bots.
|
||||
|
||||
> GitHub description: Connect IM bots to DeepSeek Harness by scanning a QR code (supports Feishu, WeChat, DingTalk, WeCom, and QQ).
|
||||
> GitHub description: Connect IM bots to DeepSeek Harness by QR code or application credentials (supports Feishu, WeChat, DingTalk, WeCom, and QQ).
|
||||
|
||||
## Interface
|
||||
|
||||
|
|
@ -66,11 +70,11 @@ Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one se
|
|||
|
||||
## Built-in channels
|
||||
|
||||
- Feishu: create and bind a bot by scanning a QR code, then send and receive messages over a persistent connection.
|
||||
- Feishu: create a bot by QR code or bind an existing bot with App ID + App Secret, then send and receive messages over a persistent connection.
|
||||
- WeChat: bind a WeChat bot by scanning a QR code, then send and receive messages through Tencent iLink long polling.
|
||||
- DingTalk: create and authorize a bot by scanning a QR code, receive messages through DingTalk Stream, and stream Harness replies through AI Cards.
|
||||
- WeCom: create and authorize an intelligent bot by scanning with the WeCom app, receive messages over the official WebSocket connection, and natively show a thinking state, tool progress, and streaming replies.
|
||||
- QQ: create or bind a QQ bot by scanning with mobile QQ, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned.
|
||||
- DingTalk: create a bot by QR code or bind an existing bot with Client ID + Client Secret, receive messages through DingTalk Stream, and stream Harness replies through AI Cards.
|
||||
- WeCom: create an intelligent bot by QR code or bind an existing bot with Bot ID + Secret, receive messages over the official WebSocket connection, and natively show a thinking state, tool progress, and streaming replies.
|
||||
- QQ: create a bot by QR code or bind an existing bot with AppID + AppSecret, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned.
|
||||
|
||||
Other IM platforms can be added through the same channel-adapter structure.
|
||||
|
||||
|
|
@ -82,11 +86,15 @@ npx -y github:xmanrui/dsh-im install
|
|||
|
||||
Restart `dsh web`, then open **Settings → Plugins → IM Bot**. The installer replaces directly installed `dsh-feishu`, `dsh-weixin`, and `dsh-dingtalk` entries in the profile with `dsh-im` without deleting channel data.
|
||||
|
||||
For DingTalk, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members.
|
||||
Feishu, QQ, DingTalk, and WeCom each provide two entry points. The blue **QR access** action uses the platform QR flow; the key-marked, outlined **Manual access** action immediately to its right connects an existing bot application. Feishu and QQ use App ID + App Secret and AppID + AppSecret respectively, DingTalk uses Client ID + Client Secret, and WeCom uses Bot ID + Secret. Secrets are sent only to the local Harness Host and stored through its protected credential provider; status responses and bot lists never return them.
|
||||
|
||||
WeCom uses the official QR authorization flow and does not require manually entering a Bot ID or Secret. Scan with a WeCom account that belongs to an enterprise and can create or manage bots, then confirm creation of the intelligent bot in the mobile app. This creates a WeCom intelligent bot; it does not sign the plugin into a personal WeChat account. There is no second local sender-approval flow, so restrict the bot's WeCom visibility to trusted enterprise members and group chats.
|
||||
For DingTalk QR binding, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members.
|
||||
|
||||
QQ uses Tencent's official QQBot v2 QR flow and does not require manually entering an AppID or AppSecret. Tencent's default authorization page labels the integration as a third-party bot. Scanning creates or binds a QQ Open Platform bot; it does not give the plugin direct control of a personal QQ account. The scanner becomes the Harness user for that bot without an additional local approval step.
|
||||
For WeCom QR binding, scan with an account that belongs to an enterprise and can create or manage bots, then confirm creation of the intelligent bot in the mobile app. This creates a WeCom intelligent bot; it does not sign the plugin into a personal WeChat account. For both QR and credential binding, restrict the bot's WeCom visibility to trusted enterprise members and group chats.
|
||||
|
||||
QQ QR binding uses Tencent's official QQBot v2 flow. Tencent's default authorization page labels the integration as a third-party bot. Scanning creates a QQ Open Platform bot; it does not give the plugin direct control of a personal QQ account. QR binding accepts only the scanner's messages. Manual credentials cannot identify a scanner, so the bot's QQ Open Platform visibility becomes its inbound access scope.
|
||||
|
||||
Feishu QR binding records the scanner as an allowed user. Manual credentials cannot identify a scanner, so the Feishu application's visibility becomes its inbound access scope. Restrict the application to trusted tenants, groups, or members.
|
||||
|
||||
## Design
|
||||
|
||||
|
|
@ -94,7 +102,7 @@ QQ uses Tencent's official QQBot v2 QR flow and does not require manually enteri
|
|||
- Maintains the Feishu, WeChat, DingTalk, WeCom, and QQ Host, client, and runtime sources in this repository without external standalone channel plugins.
|
||||
- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, and QQ navigation without enable/disable switches.
|
||||
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by channel.
|
||||
- Sends only QR codes and redacted status data to the browser, never App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, or raw user identifiers.
|
||||
- Returns only QR codes and redacted status data to the browser. Manually entered secrets travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, or raw user identifiers.
|
||||
|
||||
## Local development
|
||||
|
||||
|
|
|
|||
1970
lib/client.js
1970
lib/client.js
File diff suppressed because it is too large
Load diff
248
lib/index.js
248
lib/index.js
|
|
@ -680,6 +680,49 @@ var DingtalkController = class {
|
|||
signal?.removeEventListener("abort", abortFromRequest);
|
||||
}
|
||||
}
|
||||
/** Binds one DingTalk application with an existing Client ID and Client Secret. */
|
||||
async bindCredentials({ clientId, clientSecret } = {}) {
|
||||
if (this.#closed) throw new Error("dsh-dingtalk controller is closed");
|
||||
const normalizedClientId = cleanString3(clientId);
|
||||
const normalizedSecret = cleanString3(clientSecret);
|
||||
if (!normalizedClientId || !normalizedSecret) {
|
||||
throw new TypeError("DingTalk Client ID and Client Secret are required");
|
||||
}
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
if (this.#closed) throw new Error("dsh-dingtalk controller is closed");
|
||||
const identity = deriveDingtalkBotIdentity(normalizedClientId);
|
||||
await this.#withBotTransition(identity.botId, async () => {
|
||||
if (this.#closed) throw abortError();
|
||||
const previousConfig = this.#configStore.getByClientId(normalizedClientId);
|
||||
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0);
|
||||
if (this.#closed) throw abortError();
|
||||
const config = {
|
||||
botId: identity.botId,
|
||||
clientId: normalizedClientId,
|
||||
secretRef: identity.secretRef,
|
||||
approvedSenders: previousConfig?.approvedSenders ?? []
|
||||
};
|
||||
await this.#credentials.set(identity.secretRef, normalizedSecret);
|
||||
try {
|
||||
await this.#configStore.save(config);
|
||||
} catch (error) {
|
||||
await this.#restoreCredential(identity.secretRef, previousSecret);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(config, normalizedSecret);
|
||||
this.#errors.delete(identity.botId);
|
||||
} catch {
|
||||
this.#errors.set(
|
||||
identity.botId,
|
||||
safeError("connection-failed", "\u9489\u9489\u5DF2\u63A5\u5165\uFF0C\u4F46\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002")
|
||||
);
|
||||
this.#logger.warn?.("[dsh-dingtalk] credential-bound bot saved but its connection is not ready");
|
||||
}
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
/** Polls one QR registration without exposing its device code or returned secret. */
|
||||
async registrationStatus(attemptId) {
|
||||
const record = this.#attempts.get(attemptId);
|
||||
|
|
@ -3024,6 +3067,7 @@ var DINGTALK_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: "provision.begin",
|
||||
pollProvisioning: "provision.poll",
|
||||
cancelProvisioning: "provision.cancel",
|
||||
bindCredentials: "bot.bind-credentials",
|
||||
reconnectBot: "bot.reconnect",
|
||||
deleteBot: "bot.delete",
|
||||
approveSender: "bot.sender.approve",
|
||||
|
|
@ -3051,6 +3095,9 @@ function exactKeys(value, allowed) {
|
|||
function validId(value) {
|
||||
return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
function validCredential(value, maxLength) {
|
||||
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength;
|
||||
}
|
||||
function payloadFailure(endpoint, payload) {
|
||||
if (!isRecord(payload)) return "Payload must be an object.";
|
||||
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||
|
|
@ -3062,6 +3109,9 @@ function payloadFailure(endpoint, payload) {
|
|||
if ([DINGTALK_ENDPOINTS.pollProvisioning, DINGTALK_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
|
||||
return exactKeys(payload, ["attemptId"]) && validId(payload.attemptId) ? null : `${endpoint} requires an attemptId.`;
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) {
|
||||
return exactKeys(payload, ["clientId", "clientSecret"]) && validCredential(payload.clientId, 256) && validCredential(payload.clientSecret, 1024) ? null : "bot.bind-credentials requires Client ID and Client Secret.";
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ["botId"]) && validId(payload.botId) ? null : "bot.reconnect requires a botId.";
|
||||
}
|
||||
|
|
@ -3125,6 +3175,7 @@ function assertController(controller) {
|
|||
"startProvisioning",
|
||||
"registrationStatus",
|
||||
"cancelProvisioning",
|
||||
"bindCredentials",
|
||||
"reconnectBot",
|
||||
"deleteBot",
|
||||
"approveSender",
|
||||
|
|
@ -3171,6 +3222,8 @@ function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
|||
value = await controller.cancelProvisioning(payload.attemptId);
|
||||
if (!value) return badRequest("The provisioning attempt no longer exists.");
|
||||
value = sanitizePublic(value);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) {
|
||||
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
|
||||
|
|
@ -3591,6 +3644,7 @@ function isBotSender(event) {
|
|||
}
|
||||
function isAllowedSender(event, allowedOpenIds) {
|
||||
if (!allowedOpenIds || allowedOpenIds.size === 0) return false;
|
||||
if (allowedOpenIds.has("*")) return true;
|
||||
const senderOpenId = event?.sender?.sender_id?.open_id;
|
||||
return typeof senderOpenId === "string" && allowedOpenIds.has(senderOpenId);
|
||||
}
|
||||
|
|
@ -4838,6 +4892,7 @@ var ACTIVE_REGISTRATION_STATES2 = /* @__PURE__ */ new Set([
|
|||
"domain_switched"
|
||||
]);
|
||||
var MUTABLE_REGISTRATION_STATES = /* @__PURE__ */ new Set([...ACTIVE_REGISTRATION_STATES2, "saving"]);
|
||||
var ALL_VISIBLE_SENDERS = "*";
|
||||
function idleConnection() {
|
||||
return {
|
||||
ready: false,
|
||||
|
|
@ -5029,6 +5084,68 @@ var MultiBotDshFeishuController = class {
|
|||
selectedBotId: botId
|
||||
});
|
||||
}
|
||||
async bindCredentials({ appId, appSecret, domain = "feishu" } = {}) {
|
||||
this.#assertOpen();
|
||||
const normalizedAppId = typeof appId === "string" ? appId.trim() : "";
|
||||
const normalizedSecret = typeof appSecret === "string" ? appSecret.trim() : "";
|
||||
const normalizedDomain = domain === "lark" ? "lark" : "feishu";
|
||||
if (!normalizedAppId || !normalizedSecret) {
|
||||
throw new TypeError("Feishu App ID and App Secret are required");
|
||||
}
|
||||
return this.#serializeConfig(async () => {
|
||||
this.#assertOpen();
|
||||
const bot = await this.#verifyApp({
|
||||
appId: normalizedAppId,
|
||||
appSecret: normalizedSecret,
|
||||
domain: normalizedDomain
|
||||
});
|
||||
this.#assertOpen();
|
||||
const existing = this.#configStore.list().find(
|
||||
(candidate) => candidate.appId === normalizedAppId
|
||||
);
|
||||
const botId = existing?.id ?? this.#createBotId();
|
||||
if (typeof botId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(botId) || !existing && this.#configStore.getBot(botId)) {
|
||||
throw new Error("Bot id generator returned an invalid or duplicate id");
|
||||
}
|
||||
const secretRef = existing?.secretRef ?? secretRefFor(botId);
|
||||
const previousSecret = await this.#credentials.resolve(secretRef).catch(() => void 0);
|
||||
const config = {
|
||||
...existing,
|
||||
id: botId,
|
||||
appId: normalizedAppId,
|
||||
secretRef,
|
||||
ownerOpenIds: existing?.ownerOpenIds?.length ? existing.ownerOpenIds : [ALL_VISIBLE_SENDERS],
|
||||
domain: normalizedDomain,
|
||||
botName: bot.name,
|
||||
botOpenId: bot.openId,
|
||||
activated: bot.activated,
|
||||
deletionPending: false,
|
||||
connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
||||
createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
||||
};
|
||||
await this.#credentials.set(secretRef, normalizedSecret);
|
||||
let saved;
|
||||
try {
|
||||
saved = await this.#configStore.saveBot(config);
|
||||
} catch (error) {
|
||||
await this.#restoreCredential(secretRef, previousSecret);
|
||||
throw error;
|
||||
}
|
||||
await this.#withBotTransition(botId, async () => {
|
||||
try {
|
||||
await this.#startRuntime(saved, normalizedSecret);
|
||||
this.#botErrors.delete(botId);
|
||||
} catch {
|
||||
this.#botErrors.set(botId, {
|
||||
code: "connection_failed",
|
||||
message: "\u673A\u5668\u4EBA\u5DF2\u7ECF\u7ED1\u5B9A\uFF0C\u4F46\u957F\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u8BF7\u70B9\u51FB\u91CD\u8BD5\u3002"
|
||||
});
|
||||
}
|
||||
});
|
||||
this.#touch();
|
||||
return this.status(botId);
|
||||
});
|
||||
}
|
||||
async reconnectBot(botId) {
|
||||
this.#assertOpen();
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
|
|
@ -5571,6 +5688,7 @@ var FEISHU_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: "provision.begin",
|
||||
pollProvisioning: "provision.poll",
|
||||
cancelProvisioning: "provision.cancel",
|
||||
bindCredentials: "bot.bind-credentials",
|
||||
reconnectBot: "bot.reconnect",
|
||||
disconnectBot: "bot.disconnect",
|
||||
deleteBot: "bot.delete",
|
||||
|
|
@ -5642,6 +5760,9 @@ function finiteNumber(value) {
|
|||
function safeOpaqueId(value) {
|
||||
return typeof value === "string" && SAFE_ID.test(value);
|
||||
}
|
||||
function validCredential2(value, maxLength) {
|
||||
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength;
|
||||
}
|
||||
function publicError2(error) {
|
||||
if (!error || typeof error !== "object") return null;
|
||||
const code = typeof error.code === "string" && Object.hasOwn(PUBLIC_ERROR_MESSAGES, error.code) ? error.code : "registration_failed";
|
||||
|
|
@ -5795,6 +5916,9 @@ function validPayload(endpoint, payload) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
||||
return hasOnlyKeys(payload, /* @__PURE__ */ new Set(["appId", "appSecret"])) && validCredential2(payload.appId, 256) && validCredential2(payload.appSecret, 1024) ? null : "Credential binding requires App ID and App Secret.";
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.pollProvisioning || endpoint === FEISHU_ENDPOINTS.cancelProvisioning) {
|
||||
return hasOnlyKeys(payload, /* @__PURE__ */ new Set(["attemptId"])) && safeOpaqueId(payload.attemptId) ? null : "A single valid attemptId is required.";
|
||||
}
|
||||
|
|
@ -5929,6 +6053,14 @@ function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl } = {}) {
|
|||
if (url) qrCache.delete(url);
|
||||
attemptQr.delete(payload.attemptId);
|
||||
value = { status: "failed", message: "Registration was cancelled." };
|
||||
} else if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
||||
if (typeof controller.bindCredentials !== "function") {
|
||||
throw new Error("Credential binding is unavailable");
|
||||
}
|
||||
value = await toPublicFeishuStatus(
|
||||
await controller.bindCredentials(payload),
|
||||
{ encodeQr: cachedEncodeQr }
|
||||
);
|
||||
} else if (endpoint === FEISHU_ENDPOINTS.testConnection) {
|
||||
const current = await controller.status();
|
||||
const alreadyConnected = current?.connected === true || connectionFacts(current?.connection).connected;
|
||||
|
|
@ -6532,6 +6664,47 @@ var QqController = class {
|
|||
registrationStatus(attemptId) {
|
||||
return publicAttempt2(this.#attempts.get(attemptId));
|
||||
}
|
||||
async bindCredentials({ appId, appSecret } = {}) {
|
||||
if (this.#closed) throw new Error("QQ controller is closed");
|
||||
const normalizedAppId = cleanString6(appId);
|
||||
const normalizedSecret = cleanString6(appSecret);
|
||||
if (!normalizedAppId || !normalizedSecret) {
|
||||
throw new TypeError("QQ AppID and AppSecret are required");
|
||||
}
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
if (this.#closed) throw new Error("QQ controller is closed");
|
||||
const identity = deriveQqBotIdentity(normalizedAppId);
|
||||
await this.#withBotTransition(identity.botId, async () => {
|
||||
if (this.#closed) throw new Error("QQ controller is closed");
|
||||
const previousConfig = this.#configStore.getByAppId(normalizedAppId);
|
||||
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0);
|
||||
if (this.#closed) throw new Error("QQ controller is closed");
|
||||
const config = {
|
||||
botId: identity.botId,
|
||||
appId: normalizedAppId,
|
||||
secretRef: identity.secretRef,
|
||||
ownerUserOpenid: previousConfig?.ownerUserOpenid ?? "*",
|
||||
createdAt: previousConfig?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
||||
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
||||
};
|
||||
await this.#credentials.set(identity.secretRef, normalizedSecret);
|
||||
try {
|
||||
await this.#configStore.save(config);
|
||||
} catch (error) {
|
||||
await this.#restoreCredential(identity.secretRef, previousSecret);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(config, normalizedSecret);
|
||||
this.#errors.delete(identity.botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(identity.botId, safeError2("connection-failed", "QQ \u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002"));
|
||||
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error);
|
||||
}
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
async cancelProvisioning(attemptId) {
|
||||
const record = this.#attempts.get(attemptId);
|
||||
if (!record) return null;
|
||||
|
|
@ -6627,6 +6800,7 @@ var QqController = class {
|
|||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
await Promise.allSettled([...this.#transitions.values()]);
|
||||
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
||||
}
|
||||
async #completeProvisioning(record, credentials) {
|
||||
|
|
@ -6701,7 +6875,9 @@ var QqController = class {
|
|||
});
|
||||
}
|
||||
async #startRuntime(config, appSecret) {
|
||||
if (this.#closed) throw new Error("QQ controller is closed");
|
||||
await this.#stopRuntime(config.botId);
|
||||
if (this.#closed) throw new Error("QQ controller is closed");
|
||||
const runtime = await this.#createRuntime({ botId: config.botId, config, appSecret });
|
||||
if (!runtime || typeof runtime.start !== "function" || typeof runtime.stop !== "function") {
|
||||
throw new TypeError("createRuntime returned an invalid QQ runtime");
|
||||
|
|
@ -6824,7 +7000,7 @@ var QqHarnessBridge = class {
|
|||
if (!["c2c", "group"].includes(message.kind) || this.#state.hasSeen(messageId)) return;
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = (/* @__PURE__ */ new Date()).toISOString();
|
||||
if (sender !== this.#ownerUserOpenid) {
|
||||
if (this.#ownerUserOpenid !== "*" && sender !== this.#ownerUserOpenid) {
|
||||
this.#status.messagesRejected += 1;
|
||||
this.#status.lastRejectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
||||
return;
|
||||
|
|
@ -7012,7 +7188,7 @@ var QqRuntime = class {
|
|||
});
|
||||
bot.use?.(this.#typingMiddleware({
|
||||
keepAlive: true,
|
||||
predicate: (ctx) => ctx?.message?.senderId === this.#config.ownerUserOpenid
|
||||
predicate: (ctx) => this.#config.ownerUserOpenid === "*" || ctx?.message?.senderId === this.#config.ownerUserOpenid
|
||||
}));
|
||||
const controller = new AbortController();
|
||||
this.#abortController = controller;
|
||||
|
|
@ -7426,6 +7602,7 @@ var QQ_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: "provision.begin",
|
||||
pollProvisioning: "provision.poll",
|
||||
cancelProvisioning: "provision.cancel",
|
||||
bindCredentials: "bot.bind-credentials",
|
||||
reconnectBot: "bot.reconnect",
|
||||
deleteBot: "bot.delete"
|
||||
});
|
||||
|
|
@ -7447,6 +7624,9 @@ function exactKeys2(value, allowed) {
|
|||
function validId2(value) {
|
||||
return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
function validCredential3(value, maxLength) {
|
||||
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength;
|
||||
}
|
||||
function payloadFailure2(endpoint, payload) {
|
||||
if (!isRecord2(payload)) return "Payload must be an object.";
|
||||
if (endpoint === QQ_ENDPOINTS.status) return exactKeys2(payload, []) ? null : "connection.status does not accept fields.";
|
||||
|
|
@ -7456,6 +7636,9 @@ function payloadFailure2(endpoint, payload) {
|
|||
if ([QQ_ENDPOINTS.pollProvisioning, QQ_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
|
||||
return exactKeys2(payload, ["attemptId"]) && validId2(payload.attemptId) ? null : `${endpoint} requires an attemptId.`;
|
||||
}
|
||||
if (endpoint === QQ_ENDPOINTS.bindCredentials) {
|
||||
return exactKeys2(payload, ["appId", "appSecret"]) && validCredential3(payload.appId, 256) && validCredential3(payload.appSecret, 1024) ? null : "bot.bind-credentials requires AppID and AppSecret.";
|
||||
}
|
||||
if (endpoint === QQ_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys2(payload, ["botId"]) && validId2(payload.botId) ? null : "bot.reconnect requires a botId.";
|
||||
}
|
||||
|
|
@ -7491,7 +7674,7 @@ async function publicStatus2(status, encodeQr) {
|
|||
return sanitizePublic2(value);
|
||||
}
|
||||
function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) {
|
||||
for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "reconnectBot", "deleteBot"]) {
|
||||
for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "bindCredentials", "reconnectBot", "deleteBot"]) {
|
||||
if (typeof controller?.[method] !== "function") throw new TypeError(`A complete QQ controller is required (${method})`);
|
||||
}
|
||||
const qrCache = /* @__PURE__ */ new Map();
|
||||
|
|
@ -7519,6 +7702,8 @@ function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) {
|
|||
value = await withEncodedQr2(current, cachedEncode);
|
||||
} else if (endpoint === QQ_ENDPOINTS.cancelProvisioning) {
|
||||
value = sanitizePublic2(await controller.cancelProvisioning(payload.attemptId));
|
||||
} else if (endpoint === QQ_ENDPOINTS.bindCredentials) {
|
||||
value = await publicStatus2(await controller.bindCredentials(payload), cachedEncode);
|
||||
} else if (endpoint === QQ_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus2(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
} else {
|
||||
|
|
@ -8024,6 +8209,49 @@ var WecomController = class {
|
|||
await record.polling.catch(() => void 0);
|
||||
return publicAttempt3(record);
|
||||
}
|
||||
async bindCredentials({ botId, secret } = {}) {
|
||||
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
|
||||
const remoteBotId = cleanString9(botId);
|
||||
const normalizedSecret = cleanString9(secret);
|
||||
if (!remoteBotId || !normalizedSecret) {
|
||||
throw new TypeError("Enterprise WeChat Bot ID and Secret are required");
|
||||
}
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
|
||||
const identity = deriveWecomBotIdentity(remoteBotId);
|
||||
await this.#withBotTransition(identity.botId, async () => {
|
||||
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
|
||||
const previousConfig = this.#configStore.getByRemoteBotId(remoteBotId);
|
||||
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0);
|
||||
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
|
||||
const config = {
|
||||
botId: identity.botId,
|
||||
remoteBotId,
|
||||
secretRef: identity.secretRef,
|
||||
createdAt: previousConfig?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
||||
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
||||
};
|
||||
await this.#credentials.set(identity.secretRef, normalizedSecret);
|
||||
try {
|
||||
await this.#configStore.save(config);
|
||||
} catch (error) {
|
||||
await this.#restoreCredential(identity.secretRef, previousSecret);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(config, normalizedSecret);
|
||||
this.#errors.delete(identity.botId);
|
||||
} catch {
|
||||
this.#errors.set(
|
||||
identity.botId,
|
||||
safeError3("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002")
|
||||
);
|
||||
this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} credential connection failed`);
|
||||
}
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
async cancelProvisioning(attemptId) {
|
||||
const record = this.#attempts.get(attemptId);
|
||||
if (!record) return null;
|
||||
|
|
@ -8117,6 +8345,7 @@ var WecomController = class {
|
|||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
await Promise.allSettled([...this.#transitions.values()]);
|
||||
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
||||
}
|
||||
async #pollAttempt(record) {
|
||||
|
|
@ -8213,7 +8442,9 @@ var WecomController = class {
|
|||
});
|
||||
}
|
||||
async #startRuntime(config, secret) {
|
||||
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
|
||||
await this.#stopRuntime(config.botId);
|
||||
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
|
||||
const runtime = await this.#createRuntime({ botId: config.botId, config, secret });
|
||||
if (!runtime || typeof runtime.start !== "function" || typeof runtime.stop !== "function") {
|
||||
throw new TypeError("createRuntime returned an invalid Enterprise WeChat runtime");
|
||||
|
|
@ -8907,6 +9138,7 @@ var WECOM_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: "provision.begin",
|
||||
pollProvisioning: "provision.poll",
|
||||
cancelProvisioning: "provision.cancel",
|
||||
bindCredentials: "bot.bind-credentials",
|
||||
reconnectBot: "bot.reconnect",
|
||||
deleteBot: "bot.delete"
|
||||
});
|
||||
|
|
@ -8929,6 +9161,9 @@ function exactKeys3(value, allowed) {
|
|||
function validId3(value) {
|
||||
return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
function validCredential4(value, maxLength) {
|
||||
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength;
|
||||
}
|
||||
function payloadFailure3(endpoint, payload) {
|
||||
if (!isRecord3(payload)) return "Payload must be an object.";
|
||||
if (endpoint === WECOM_ENDPOINTS.status) return exactKeys3(payload, []) ? null : "connection.status does not accept fields.";
|
||||
|
|
@ -8938,6 +9173,9 @@ function payloadFailure3(endpoint, payload) {
|
|||
if ([WECOM_ENDPOINTS.pollProvisioning, WECOM_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
|
||||
return exactKeys3(payload, ["attemptId"]) && validId3(payload.attemptId) ? null : `${endpoint} requires an attemptId.`;
|
||||
}
|
||||
if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
|
||||
return exactKeys3(payload, ["botId", "secret"]) && validCredential4(payload.botId, 512) && validCredential4(payload.secret, 1024) ? null : "bot.bind-credentials requires Bot ID and Secret.";
|
||||
}
|
||||
if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys3(payload, ["botId"]) && validId3(payload.botId) ? null : "bot.reconnect requires a botId.";
|
||||
}
|
||||
|
|
@ -8973,7 +9211,7 @@ async function publicStatus3(status, encodeQr) {
|
|||
return sanitizePublic3(value);
|
||||
}
|
||||
function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) {
|
||||
for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "reconnectBot", "deleteBot"]) {
|
||||
for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "bindCredentials", "reconnectBot", "deleteBot"]) {
|
||||
if (typeof controller?.[method] !== "function") {
|
||||
throw new TypeError(`A complete Enterprise WeChat controller is required (${method})`);
|
||||
}
|
||||
|
|
@ -9006,6 +9244,8 @@ function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) {
|
|||
value = await withEncodedQr3(current, cachedEncode);
|
||||
} else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) {
|
||||
value = sanitizePublic3(await controller.cancelProvisioning(payload.attemptId));
|
||||
} else if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
|
||||
value = await publicStatus3(await controller.bindCredentials(payload), cachedEncode);
|
||||
} else if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus3(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@xmanrui/dsh-im",
|
||||
"version": "0.1.0",
|
||||
"description": "通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。",
|
||||
"description": "通过扫码或应用凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
bindCredentials: 'bot.bind-credentials',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
|
||||
import {
|
||||
DINGTALK_ENDPOINTS,
|
||||
DINGTALK_RPC_CHANNEL,
|
||||
|
|
@ -46,20 +47,30 @@ const Button = React.forwardRef(function Button(
|
|||
}, children);
|
||||
});
|
||||
|
||||
function Heading({ totals, adding, busy, onAdd, addButtonRef }) {
|
||||
function Heading({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) {
|
||||
return h('div', { className: 'ddt-heading' },
|
||||
h('div', { className: 'ddt-headingCopy' },
|
||||
h('div', { className: 'ddt-eyebrow' }, 'Channel'),
|
||||
h('h2', null, '钉钉机器人'),
|
||||
h('p', null, '通过扫码把钉钉机器人接入 DeepSeek Harness')),
|
||||
h('div', { className: 'ddt-tools' },
|
||||
h(Button, {
|
||||
kind: 'primary',
|
||||
className: 'dim-scanButton',
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
}, adding ? '正在接入' : '扫码接入钉钉'),
|
||||
h('div', { className: 'dim-bindActions' },
|
||||
h(Button, {
|
||||
kind: 'primary',
|
||||
className: 'dim-scanButton',
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
'aria-label': '扫码接入钉钉机器人',
|
||||
}, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'),
|
||||
h(Button, {
|
||||
kind: 'credential',
|
||||
className: 'dim-credentialButton',
|
||||
onClick: onCredential,
|
||||
disabled: adding || busy,
|
||||
'aria-pressed': credentialOpen,
|
||||
'aria-label': '使用 Client ID 和 Client Secret 绑定钉钉机器人',
|
||||
}, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')),
|
||||
totals.configured > 0
|
||||
? h('div', { className: 'ddt-badge dim-onlineBadge' },
|
||||
h('span', null, `${totals.connected} / ${totals.configured} 在线`))
|
||||
|
|
@ -204,6 +215,7 @@ export function AccountCard({
|
|||
const state = busy === 'reconnect' ? 'connecting' : account.state;
|
||||
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
|
||||
const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
|
||||
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
|
||||
return h('article', { className: 'ddt-card dim-botCard', tabIndex: -1, 'data-bot-id': account.botId },
|
||||
h('div', { className: 'ddt-cardBody dim-botCardBody' },
|
||||
h('div', { className: 'ddt-accountTop dim-botCardTop' },
|
||||
|
|
@ -220,7 +232,7 @@ export function AccountCard({
|
|||
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'),
|
||||
h('dd', null, checkedTime(account.health.lastCheckedAt)))),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
h('div', { className: 'ddt-summary dim-cardSummary' }, account.error?.message ?? account.health.summary),
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
|
||||
busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
|
|
@ -237,7 +249,7 @@ export function AccountCard({
|
|||
function AccountList(props) {
|
||||
return h('section', { className: 'dim-listSection' },
|
||||
h('div', { className: 'ddt-listHeading dim-listHeading' },
|
||||
h('h3', null, '已接入的钉钉机器人'), h('span', null, `${props.bots.length} 个`)),
|
||||
h('h3', null, '已接入的钉钉机器人')),
|
||||
h('ul', { className: 'ddt-list dim-botList' }, props.bots.map((account) => h('li', { key: account.botId },
|
||||
h(AccountCard, {
|
||||
account,
|
||||
|
|
@ -260,6 +272,8 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
||||
const [credentialError, setCredentialError] = React.useState(null);
|
||||
const [notice, setNotice] = React.useState('');
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
const addButtonRef = React.useRef(null);
|
||||
|
|
@ -397,6 +411,8 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
|
||||
const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
|
||||
if (!mountedRef.current) return;
|
||||
setCredentialOpen(false);
|
||||
setCredentialError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
if (replace && provision?.attemptId) {
|
||||
|
|
@ -432,6 +448,32 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, [announce, invoke, provision?.attemptId]);
|
||||
|
||||
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
|
||||
if (!mountedRef.current) return;
|
||||
setBusy(true);
|
||||
setCredentialError(null);
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(
|
||||
DINGTALK_ENDPOINTS.bindCredentials,
|
||||
{ clientId: identity, clientSecret: secret },
|
||||
));
|
||||
if (!mountedRef.current) return;
|
||||
setModel({
|
||||
phase: 'ready',
|
||||
bots: snapshot.bots,
|
||||
totals: snapshot.totals,
|
||||
revision: snapshot.revision,
|
||||
error: null,
|
||||
});
|
||||
setCredentialOpen(false);
|
||||
announce('钉钉机器人凭据已绑定。');
|
||||
} catch (error) {
|
||||
if (mountedRef.current) setCredentialError(presentError(error));
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(false);
|
||||
}
|
||||
}, [announce, invoke]);
|
||||
|
||||
const cancelProvisioning = React.useCallback(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
setBusy(true);
|
||||
|
|
@ -598,12 +640,28 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
});
|
||||
}
|
||||
|
||||
const credentialView = credentialOpen
|
||||
? h(CredentialBindingPanel, {
|
||||
channel: '钉钉',
|
||||
identityLabel: 'Client ID',
|
||||
identityPlaceholder: '填写钉钉应用 Client ID',
|
||||
secretLabel: 'Client Secret',
|
||||
secretPlaceholder: '填写钉钉应用 Client Secret',
|
||||
busy,
|
||||
error: credentialError,
|
||||
onSubmit: bindCredentials,
|
||||
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
||||
})
|
||||
: null;
|
||||
|
||||
return h('section', { className: 'ddt-page dim-channelPage', 'aria-label': '钉钉设置' },
|
||||
h(Heading, {
|
||||
totals: model.totals,
|
||||
adding: Boolean(provision),
|
||||
busy,
|
||||
onAdd: () => void startProvisioning(),
|
||||
onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); },
|
||||
credentialOpen,
|
||||
addButtonRef,
|
||||
}),
|
||||
h('div', { className: 'ddt-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice),
|
||||
|
|
@ -619,8 +677,9 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
h('p', null, model.error?.message ?? '请稍后重试'),
|
||||
h(Button, { onClick: () => void loadStatus() }, '重新读取')))
|
||||
: h(React.Fragment, null,
|
||||
credentialView,
|
||||
provisionView,
|
||||
model.bots.length === 0 && !provision
|
||||
model.bots.length === 0 && !provision && !credentialOpen
|
||||
? h(EmptyView, { busy, onStart: () => void startProvisioning() })
|
||||
: null,
|
||||
model.bots.length > 0
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@ const CSS = String.raw`
|
|||
.ddt-errorCode { font: 11px ui-monospace, SFMono-Regular, monospace; opacity: .8; }
|
||||
.ddt-listHeading { display: flex; align-items: center; justify-content: space-between; margin: 2px 0 9px; }
|
||||
.ddt-listHeading h3 { margin: 0; font-size: 14px; }
|
||||
.ddt-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; }
|
||||
.ddt-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||
.ddt-accountTop { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.ddt-accountIdentity { min-width: 0; display: flex; align-items: center; gap: 12px; }
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: "provision.begin",
|
||||
pollProvisioning: "provision.poll",
|
||||
cancelProvisioning: "provision.cancel",
|
||||
bindCredentials: "bot.bind-credentials",
|
||||
reconnectBot: "bot.reconnect",
|
||||
disconnectBot: "bot.disconnect",
|
||||
deleteBot: "bot.delete",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { FeishuLogoGlyph } from "../../channel-logos.js";
|
||||
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from "../../credential-binding.js";
|
||||
import {
|
||||
FEISHU_ENDPOINTS,
|
||||
FEISHU_RPC_CHANNEL,
|
||||
|
|
@ -125,19 +126,32 @@ function BrandMark() {
|
|||
return h("div", { className: "bxf-brandMark" }, h(RobotIcon, { size: 34 }));
|
||||
}
|
||||
|
||||
function Heading({ totals, onAdd, adding, busy, addButtonRef }) {
|
||||
function Heading({ totals, onAdd, onCredential, credentialOpen, adding, busy, addButtonRef }) {
|
||||
const hasBots = totals.configured > 0;
|
||||
return h("div", { className: "bxf-heading" },
|
||||
h("div", { className: "bxf-headingTools" },
|
||||
h(Button, {
|
||||
kind: "primary",
|
||||
size: "small",
|
||||
className: "bxf-bindButton dim-scanButton",
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
"aria-busy": busy ? "true" : undefined,
|
||||
}, adding ? "正在绑定" : "扫码绑定机器人"),
|
||||
h("div", { className: "dim-bindActions" },
|
||||
h(Button, {
|
||||
kind: "primary",
|
||||
size: "small",
|
||||
className: "bxf-bindButton dim-scanButton",
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
"aria-busy": busy ? "true" : undefined,
|
||||
"aria-label": "扫码接入飞书机器人",
|
||||
icon: h(QrActionIcon),
|
||||
}, adding ? "正在接入" : "扫码接入机器人"),
|
||||
h(Button, {
|
||||
kind: "credential",
|
||||
size: "small",
|
||||
className: "dim-credentialButton",
|
||||
onClick: onCredential,
|
||||
disabled: adding || busy,
|
||||
"aria-pressed": credentialOpen,
|
||||
"aria-label": "使用 App ID 和 App Secret 绑定飞书机器人",
|
||||
icon: h(CredentialActionIcon),
|
||||
}, credentialOpen ? "收起凭据" : "手动接入")),
|
||||
hasBots
|
||||
? h("div", {
|
||||
className: "bxf-totalBadge dim-onlineBadge",
|
||||
|
|
@ -368,7 +382,7 @@ export function BotCard({
|
|||
: "error";
|
||||
const summary = actionError?.message
|
||||
?? connection.error?.message
|
||||
?? health.summary;
|
||||
?? (connected ? null : health.summary);
|
||||
const titleId = `bxf-bot-${connection.botId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||
return h("article", {
|
||||
className: "bxf-card bxf-botCard dim-botCard",
|
||||
|
|
@ -397,8 +411,8 @@ export function BotCard({
|
|||
h("dd", null, formatCheckedTime(health.lastCheckedAt))),
|
||||
),
|
||||
h("div", { className: "bxf-connectedFooter dim-cardFooter" },
|
||||
h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
|
||||
summary),
|
||||
summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
|
||||
summary) : null,
|
||||
h("div", { className: "bxf-actions bxf-botActions dim-cardActions" },
|
||||
h(Button, {
|
||||
className: "dim-cardAction", onClick: onReconnect,
|
||||
|
|
@ -426,8 +440,7 @@ export function BotCard({
|
|||
function BotList(props) {
|
||||
return h("section", { className: "bxf-listSection dim-listSection", "aria-labelledby": "bxf-bot-list-title" },
|
||||
h("div", { className: "bxf-listHeading dim-listHeading" },
|
||||
h("h3", { id: "bxf-bot-list-title" }, "已接入的机器人"),
|
||||
h("span", null, `${props.bots.length} 个`)),
|
||||
h("h3", { id: "bxf-bot-list-title" }, "已接入的机器人")),
|
||||
h("ul", { className: "bxf-botList dim-botList", role: "list" },
|
||||
props.bots.map((bot) => h("li", { key: bot.botId },
|
||||
h(BotCard, {
|
||||
|
|
@ -501,6 +514,9 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
});
|
||||
const [pageBusy, setPageBusy] = React.useState(false);
|
||||
const [provisionBusy, setProvisionBusy] = React.useState(false);
|
||||
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
||||
const [credentialBusy, setCredentialBusy] = React.useState(false);
|
||||
const [credentialError, setCredentialError] = React.useState(null);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [errorsByBot, setErrorsByBot] = React.useState({});
|
||||
const [removeTargetId, setRemoveTargetId] = React.useState(null);
|
||||
|
|
@ -589,6 +605,8 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
}, [focusBotId, model.bots]);
|
||||
|
||||
const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
|
||||
setCredentialOpen(false);
|
||||
setCredentialError(null);
|
||||
setProvisionBusy(true);
|
||||
announce("");
|
||||
const previousAttemptId = model.provisioning?.attemptId;
|
||||
|
|
@ -627,6 +645,24 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, [announce, invoke, model.provisioning?.attemptId]);
|
||||
|
||||
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
|
||||
setCredentialBusy(true);
|
||||
setCredentialError(null);
|
||||
try {
|
||||
const snapshot = normalizeBotsSnapshot(await invoke(
|
||||
FEISHU_ENDPOINTS.bindCredentials,
|
||||
{ appId: identity, appSecret: secret },
|
||||
));
|
||||
mergeSnapshot(snapshot);
|
||||
setCredentialOpen(false);
|
||||
announce("飞书机器人凭据已绑定。");
|
||||
} catch (error) {
|
||||
setCredentialError(presentError(error));
|
||||
} finally {
|
||||
setCredentialBusy(false);
|
||||
}
|
||||
}, [announce, invoke, mergeSnapshot]);
|
||||
|
||||
const cancelProvisioning = React.useCallback(async () => {
|
||||
const attemptId = model.provisioning?.attemptId;
|
||||
setProvisionBusy(true);
|
||||
|
|
@ -852,6 +888,20 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
});
|
||||
}
|
||||
|
||||
const credentialContent = credentialOpen
|
||||
? h(CredentialBindingPanel, {
|
||||
channel: "飞书",
|
||||
identityLabel: "App ID",
|
||||
identityPlaceholder: "填写飞书开放平台 App ID",
|
||||
secretLabel: "App Secret",
|
||||
secretPlaceholder: "填写飞书开放平台 App Secret",
|
||||
busy: credentialBusy,
|
||||
error: credentialError,
|
||||
onSubmit: bindCredentials,
|
||||
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
||||
})
|
||||
: null;
|
||||
|
||||
const setCardRef = React.useCallback((botId, node) => {
|
||||
if (node) cardRefs.current.set(botId, node);
|
||||
else cardRefs.current.delete(botId);
|
||||
|
|
@ -865,8 +915,10 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
h(Heading, {
|
||||
totals: model.totals,
|
||||
onAdd: () => void startProvisioning(),
|
||||
onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); },
|
||||
credentialOpen,
|
||||
adding: Boolean(provision),
|
||||
busy: provisionBusy,
|
||||
busy: provisionBusy || credentialBusy,
|
||||
addButtonRef,
|
||||
}),
|
||||
h("div", {
|
||||
|
|
@ -887,8 +939,9 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
busy: pageBusy,
|
||||
})
|
||||
: h(React.Fragment, null,
|
||||
credentialContent,
|
||||
provisionContent,
|
||||
model.bots.length === 0 && !provision
|
||||
model.bots.length === 0 && !provision && !credentialOpen
|
||||
? h(EmptyView, { onStart: () => void startProvisioning(), busy: provisionBusy })
|
||||
: null,
|
||||
model.bots.length > 0
|
||||
|
|
|
|||
|
|
@ -386,7 +386,6 @@ const CSS = String.raw`
|
|||
.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; }
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const QQ_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
bindCredentials: 'bot.bind-credentials',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { QqLogoGlyph } from '../../channel-logos.js';
|
||||
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
|
||||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
import {
|
||||
QQ_ENDPOINTS,
|
||||
|
|
@ -38,16 +39,26 @@ function checkedTime(value) {
|
|||
}
|
||||
}
|
||||
|
||||
function Heading({ totals, adding, busy, onAdd, addButtonRef }) {
|
||||
function Heading({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) {
|
||||
return h('div', { className: 'ddt-heading' },
|
||||
h('div', { className: 'ddt-tools' },
|
||||
h(Button, {
|
||||
kind: 'primary',
|
||||
className: 'dim-scanButton',
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
}, adding ? '正在绑定' : '扫码绑定QQ机器人'),
|
||||
h('div', { className: 'dim-bindActions' },
|
||||
h(Button, {
|
||||
kind: 'primary',
|
||||
className: 'dim-scanButton',
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
'aria-label': '扫码接入 QQ 机器人',
|
||||
}, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'),
|
||||
h(Button, {
|
||||
kind: 'credential',
|
||||
className: 'dim-credentialButton',
|
||||
onClick: onCredential,
|
||||
disabled: adding || busy,
|
||||
'aria-pressed': credentialOpen,
|
||||
'aria-label': '使用 AppID 和 AppSecret 绑定 QQ 机器人',
|
||||
}, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')),
|
||||
totals.configured > 0
|
||||
? h('div', { className: 'ddt-badge dim-onlineBadge' },
|
||||
h('span', null, `${totals.connected} / ${totals.configured} 在线`))
|
||||
|
|
@ -138,6 +149,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
|
|||
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
|
||||
const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
|
||||
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
|
||||
return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId },
|
||||
h('div', { className: 'ddt-cardBody dim-botCardBody' },
|
||||
h('div', { className: 'ddt-accountTop dim-botCardTop' },
|
||||
|
|
@ -151,7 +163,7 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
|
|||
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')),
|
||||
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
h('div', { className: 'ddt-summary dim-cardSummary' }, account.error?.message ?? account.health.summary),
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
|
||||
|
|
@ -166,6 +178,8 @@ export function QqSettingsTab({ rpcCall }) {
|
|||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
||||
const [credentialError, setCredentialError] = React.useState(null);
|
||||
const [now, setNow] = React.useState(Date.now());
|
||||
const mounted = React.useRef(true);
|
||||
const addButtonRef = React.useRef(null);
|
||||
|
|
@ -225,6 +239,8 @@ export function QqSettingsTab({ rpcCall }) {
|
|||
}, [provision?.attemptId, provision?.status]);
|
||||
|
||||
const startProvisioning = React.useCallback(async (replace = false) => {
|
||||
setCredentialOpen(false);
|
||||
setCredentialError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
if (replace && provision?.attemptId) await invoke(QQ_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
|
||||
|
|
@ -241,6 +257,24 @@ export function QqSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, [invoke, provision?.attemptId]);
|
||||
|
||||
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
|
||||
setBusy(true);
|
||||
setCredentialError(null);
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(
|
||||
QQ_ENDPOINTS.bindCredentials,
|
||||
{ appId: identity, appSecret: secret },
|
||||
));
|
||||
if (!mounted.current) return;
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
setCredentialOpen(false);
|
||||
} catch (error) {
|
||||
if (mounted.current) setCredentialError(presentError(error));
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [invoke]);
|
||||
|
||||
const closeProvision = React.useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
|
|
@ -308,7 +342,7 @@ export function QqSettingsTab({ rpcCall }) {
|
|||
const botList = model.bots.length > 0
|
||||
? h('section', { className: 'dim-listSection' },
|
||||
h('div', { className: 'ddt-listHeading dim-listHeading' },
|
||||
h('h3', null, '已绑定的 QQ 机器人'), h('span', null, `${model.bots.length} 个`)),
|
||||
h('h3', null, '已绑定的 QQ 机器人')),
|
||||
h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
|
||||
h('li', { key: account.botId }, h(AccountCard, {
|
||||
account,
|
||||
|
|
@ -324,14 +358,38 @@ export function QqSettingsTab({ rpcCall }) {
|
|||
})))))
|
||||
: null;
|
||||
|
||||
const credentialView = credentialOpen
|
||||
? h(CredentialBindingPanel, {
|
||||
channel: 'QQ',
|
||||
identityLabel: 'AppID',
|
||||
identityPlaceholder: '填写 QQ 开放平台 AppID',
|
||||
secretLabel: 'AppSecret',
|
||||
secretPlaceholder: '填写 QQ 开放平台 AppSecret',
|
||||
busy,
|
||||
error: credentialError,
|
||||
onSubmit: bindCredentials,
|
||||
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
||||
})
|
||||
: null;
|
||||
|
||||
return h('section', { className: 'ddt-page dqq-page dim-channelPage', 'aria-label': 'QQ 设置' },
|
||||
h(Heading, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }),
|
||||
h(Heading, {
|
||||
totals: model.totals,
|
||||
adding: Boolean(provision),
|
||||
busy,
|
||||
onAdd: () => void startProvisioning(),
|
||||
onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); },
|
||||
credentialOpen,
|
||||
addButtonRef,
|
||||
}),
|
||||
model.phase === 'loading' ? h(LoadingView)
|
||||
: model.phase === 'error'
|
||||
? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取 QQ 机器人状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取')))
|
||||
: h(React.Fragment, null,
|
||||
credentialView,
|
||||
provisionView,
|
||||
model.bots.length === 0 && !provision ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
|
||||
model.bots.length === 0 && !provision && !credentialOpen
|
||||
? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
|
||||
botList));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
bindCredentials: 'bot.bind-credentials',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { WecomLogoGlyph } from '../../channel-logos.js';
|
||||
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
|
||||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
import {
|
||||
WECOM_ENDPOINTS,
|
||||
|
|
@ -38,16 +39,26 @@ function checkedTime(value) {
|
|||
}
|
||||
}
|
||||
|
||||
function Heading({ totals, adding, busy, onAdd, addButtonRef }) {
|
||||
function Heading({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) {
|
||||
return h('div', { className: 'ddt-heading' },
|
||||
h('div', { className: 'ddt-tools' },
|
||||
h(Button, {
|
||||
kind: 'primary',
|
||||
className: 'dim-scanButton',
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
}, adding ? '正在绑定' : '扫码绑定企业微信机器人'),
|
||||
h('div', { className: 'dim-bindActions' },
|
||||
h(Button, {
|
||||
kind: 'primary',
|
||||
className: 'dim-scanButton',
|
||||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
'aria-label': '扫码接入企业微信机器人',
|
||||
}, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'),
|
||||
h(Button, {
|
||||
kind: 'credential',
|
||||
className: 'dim-credentialButton',
|
||||
onClick: onCredential,
|
||||
disabled: adding || busy,
|
||||
'aria-pressed': credentialOpen,
|
||||
'aria-label': '使用 Bot ID 和 Secret 绑定企业微信机器人',
|
||||
}, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')),
|
||||
totals.configured > 0
|
||||
? h('div', { className: 'ddt-badge dim-onlineBadge' },
|
||||
h('span', null, `${totals.connected} / ${totals.configured} 在线`))
|
||||
|
|
@ -138,6 +149,7 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
|
|||
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
|
||||
const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
|
||||
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
|
||||
return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId },
|
||||
h('div', { className: 'ddt-cardBody dim-botCardBody' },
|
||||
h('div', { className: 'ddt-accountTop dim-botCardTop' },
|
||||
|
|
@ -151,7 +163,7 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
|
|||
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')),
|
||||
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
h('div', { className: 'ddt-summary dim-cardSummary' }, account.error?.message ?? account.health.summary),
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
|
||||
|
|
@ -166,6 +178,8 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
||||
const [credentialError, setCredentialError] = React.useState(null);
|
||||
const [now, setNow] = React.useState(Date.now());
|
||||
const mounted = React.useRef(true);
|
||||
const addButtonRef = React.useRef(null);
|
||||
|
|
@ -225,6 +239,8 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
}, [provision?.attemptId, provision?.status]);
|
||||
|
||||
const startProvisioning = React.useCallback(async (replace = false) => {
|
||||
setCredentialOpen(false);
|
||||
setCredentialError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
if (replace && provision?.attemptId) await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
|
||||
|
|
@ -241,6 +257,24 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, [invoke, provision?.attemptId]);
|
||||
|
||||
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
|
||||
setBusy(true);
|
||||
setCredentialError(null);
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(
|
||||
WECOM_ENDPOINTS.bindCredentials,
|
||||
{ botId: identity, secret },
|
||||
));
|
||||
if (!mounted.current) return;
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
setCredentialOpen(false);
|
||||
} catch (error) {
|
||||
if (mounted.current) setCredentialError(presentError(error));
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [invoke]);
|
||||
|
||||
const closeProvision = React.useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
|
|
@ -308,7 +342,7 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
const botList = model.bots.length > 0
|
||||
? h('section', { className: 'dim-listSection' },
|
||||
h('div', { className: 'ddt-listHeading dim-listHeading' },
|
||||
h('h3', null, '已绑定的企业微信机器人'), h('span', null, `${model.bots.length} 个`)),
|
||||
h('h3', null, '已绑定的企业微信机器人')),
|
||||
h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
|
||||
h('li', { key: account.botId }, h(AccountCard, {
|
||||
account,
|
||||
|
|
@ -324,14 +358,38 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
})))))
|
||||
: null;
|
||||
|
||||
const credentialView = credentialOpen
|
||||
? h(CredentialBindingPanel, {
|
||||
channel: '企业微信',
|
||||
identityLabel: 'Bot ID',
|
||||
identityPlaceholder: '填写企业微信智能机器人 Bot ID',
|
||||
secretLabel: 'Secret',
|
||||
secretPlaceholder: '填写企业微信智能机器人 Secret',
|
||||
busy,
|
||||
error: credentialError,
|
||||
onSubmit: bindCredentials,
|
||||
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
||||
})
|
||||
: null;
|
||||
|
||||
return h('section', { className: 'ddt-page dwecom-page dim-channelPage', 'aria-label': '企业微信设置' },
|
||||
h(Heading, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }),
|
||||
h(Heading, {
|
||||
totals: model.totals,
|
||||
adding: Boolean(provision),
|
||||
busy,
|
||||
onAdd: () => void startProvisioning(),
|
||||
onCredential: () => { setCredentialOpen((value) => !value); setCredentialError(null); },
|
||||
credentialOpen,
|
||||
addButtonRef,
|
||||
}),
|
||||
model.phase === 'loading' ? h(LoadingView)
|
||||
: model.phase === 'error'
|
||||
? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取企业微信机器人状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取')))
|
||||
: h(React.Fragment, null,
|
||||
credentialView,
|
||||
provisionView,
|
||||
model.bots.length === 0 && !provision ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
|
||||
model.bots.length === 0 && !provision && !credentialOpen
|
||||
? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
|
||||
botList));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { WeixinLogoGlyph } from '../../channel-logos.js';
|
||||
import { QrActionIcon } from '../../credential-binding.js';
|
||||
import {
|
||||
WEIXIN_ENDPOINTS,
|
||||
WEIXIN_RPC_CHANNEL,
|
||||
|
|
@ -42,7 +43,8 @@ function Heading({ totals, adding, busy, onAdd, addButtonRef }) {
|
|||
onClick: onAdd,
|
||||
disabled: adding || busy,
|
||||
ref: addButtonRef,
|
||||
}, adding ? '正在绑定' : '扫码绑定微信'),
|
||||
'aria-label': '扫码接入微信机器人',
|
||||
}, h(QrActionIcon), adding ? '正在接入' : '扫码接入机器人'),
|
||||
totals.configured > 0
|
||||
? h('div', { className: 'dxw-badge dim-onlineBadge' },
|
||||
h('span', null, `${totals.connected} / ${totals.configured} 在线`))
|
||||
|
|
@ -195,6 +197,7 @@ function checkedTime(timestamp) {
|
|||
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
const state = busy === 'reconnect' ? 'connecting' : account.state;
|
||||
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
|
||||
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
|
||||
return h('article', { className: 'dxw-card dim-botCard', tabIndex: -1, 'data-bot-id': account.botId },
|
||||
h('div', { className: 'dxw-cardBody dim-botCardBody' },
|
||||
h('div', { className: 'dxw-accountTop dim-botCardTop' },
|
||||
|
|
@ -210,7 +213,7 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
|
|||
h('div', { className: 'dxw-metric dim-botMetric' }, h('dt', null, '最近检查'),
|
||||
h('dd', null, checkedTime(account.health.lastCheckedAt)))),
|
||||
h('div', { className: 'dxw-accountFooter dim-cardFooter' },
|
||||
h('div', { className: 'dxw-summary dim-cardSummary' }, account.error?.message ?? account.health.summary),
|
||||
summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
|
||||
h('div', { className: 'dxw-actions dim-cardActions' },
|
||||
h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
|
||||
busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
|
|
@ -227,7 +230,7 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
|
|||
|
||||
function AccountList(props) {
|
||||
return h('section', { className: 'dim-listSection' },
|
||||
h('div', { className: 'dxw-listHeading dim-listHeading' }, h('h3', null, '已接入的微信账号'), h('span', null, `${props.bots.length} 个`)),
|
||||
h('div', { className: 'dxw-listHeading dim-listHeading' }, h('h3', null, '已接入的微信账号')),
|
||||
h('ul', { className: 'dxw-list dim-botList' }, props.bots.map((account) => h('li', { key: account.botId },
|
||||
h(AccountCard, {
|
||||
account,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ const CSS = String.raw`
|
|||
.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; }
|
||||
|
|
|
|||
113
plugin-src/client/credential-binding.js
Normal file
113
plugin-src/client/credential-binding.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import * as React from 'react';
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
function ActionIcon({ children }) {
|
||||
return h('svg', {
|
||||
className: 'dim-actionIcon',
|
||||
width: 15,
|
||||
height: 15,
|
||||
viewBox: '0 0 20 20',
|
||||
fill: 'none',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
'aria-hidden': 'true',
|
||||
focusable: 'false',
|
||||
}, children);
|
||||
}
|
||||
|
||||
export function QrActionIcon() {
|
||||
return h(ActionIcon, null,
|
||||
h('path', {
|
||||
d: 'M2.5 2.5h5v5h-5v-5Zm10 0h5v5h-5v-5Zm-10 10h5v5h-5v-5Z',
|
||||
stroke: 'currentColor', strokeWidth: '1.6', strokeLinejoin: 'round',
|
||||
}),
|
||||
h('path', {
|
||||
d: 'M11.5 11.5h2v2h-2v-2Zm4 0h2v3h-2v-3Zm-4 4h3v2h-3v-2Zm5 1h1v1h-1v-1Z',
|
||||
fill: 'currentColor',
|
||||
}));
|
||||
}
|
||||
|
||||
export function CredentialActionIcon() {
|
||||
return h(ActionIcon, null,
|
||||
h('circle', {
|
||||
cx: '6.25', cy: '10', r: '3.5', stroke: 'currentColor', strokeWidth: '1.6',
|
||||
}),
|
||||
h('path', {
|
||||
d: 'M9.75 10h7.75m-2.5 0v2m-2.5-2v2',
|
||||
stroke: 'currentColor', strokeWidth: '1.6', strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
}));
|
||||
}
|
||||
|
||||
export function CredentialBindingPanel({
|
||||
channel,
|
||||
identityLabel,
|
||||
identityPlaceholder,
|
||||
secretLabel,
|
||||
secretPlaceholder,
|
||||
busy = false,
|
||||
error = null,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) {
|
||||
const [identity, setIdentity] = React.useState('');
|
||||
const [secret, setSecret] = React.useState('');
|
||||
const headingId = React.useId();
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
const normalizedIdentity = identity.trim();
|
||||
const normalizedSecret = secret.trim();
|
||||
if (!normalizedIdentity || !normalizedSecret || busy) return;
|
||||
void onSubmit?.({ identity: normalizedIdentity, secret: normalizedSecret });
|
||||
};
|
||||
|
||||
return h('section', {
|
||||
className: 'ddt-card dim-surfaceCard dim-credentialPanel',
|
||||
'aria-labelledby': headingId,
|
||||
},
|
||||
h('h3', { id: headingId, className: 'dim-credentialTitle' }, `手动接入${channel}机器人`),
|
||||
h('form', { className: 'dim-credentialForm', onSubmit: submit },
|
||||
h('label', { className: 'dim-credentialField' },
|
||||
h('span', null, identityLabel),
|
||||
h('input', {
|
||||
value: identity,
|
||||
onChange: (event) => setIdentity(event.target.value),
|
||||
placeholder: identityPlaceholder,
|
||||
maxLength: 512,
|
||||
autoCapitalize: 'none',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: false,
|
||||
autoComplete: 'off',
|
||||
disabled: busy,
|
||||
required: true,
|
||||
})),
|
||||
h('label', { className: 'dim-credentialField' },
|
||||
h('span', null, secretLabel),
|
||||
h('input', {
|
||||
type: 'password',
|
||||
value: secret,
|
||||
onChange: (event) => setSecret(event.target.value),
|
||||
placeholder: secretPlaceholder,
|
||||
maxLength: 1024,
|
||||
autoCapitalize: 'none',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: false,
|
||||
autoComplete: 'new-password',
|
||||
disabled: busy,
|
||||
required: true,
|
||||
})),
|
||||
error ? h('p', { className: 'dim-credentialError', role: 'alert' }, error.message ?? String(error)) : null,
|
||||
h('div', { className: 'ddt-actions dim-viewActions dim-credentialActions' },
|
||||
h('button', {
|
||||
type: 'submit',
|
||||
className: 'ddt-button',
|
||||
'data-kind': 'primary',
|
||||
disabled: busy || !identity.trim() || !secret.trim(),
|
||||
}, busy ? '正在绑定…' : '绑定并连接'),
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: 'ddt-button',
|
||||
onClick: onCancel,
|
||||
disabled: busy,
|
||||
}, '取消'))));
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ const CSS = String.raw`
|
|||
.dim-logoWeixin { color: white; background: #07c160; }
|
||||
.dim-logoWeixin svg { width: 19px; height: 19px; }
|
||||
.dim-logoFeishu { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); }
|
||||
.dim-logoFeishu svg { width: 22px; height: 22px; }
|
||||
.dim-logoFeishu svg { width: 28px; height: 28px; }
|
||||
.dim-logoDingtalk { color: white; background: #1677ff; }
|
||||
.dim-logoDingtalk svg { width: 24px; height: 24px; }
|
||||
.dim-logoQq { color: white; background: #1677ff; }
|
||||
|
|
@ -40,17 +40,31 @@ const CSS = String.raw`
|
|||
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page { width: 100%; max-width: none; padding: 0 0 24px; }
|
||||
.dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; }
|
||||
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; justify-content: space-between; }
|
||||
.dim-panel .bxf-headingTools .dim-scanButton, .dim-panel .dxw-tools .dim-scanButton, .dim-panel .ddt-tools .dim-scanButton { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; box-shadow: none; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; }
|
||||
.dim-panel .dim-bindActions { min-width: 0; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; }
|
||||
.dim-panel .bxf-headingTools .dim-scanButton, .dim-panel .dxw-tools .dim-scanButton, .dim-panel .ddt-tools .dim-scanButton { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 10px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; box-shadow: none; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; }
|
||||
.dim-panel .bxf-headingTools .dim-scanButton:hover:not(:disabled), .dim-panel .dxw-tools .dim-scanButton:hover:not(:disabled), .dim-panel .ddt-tools .dim-scanButton:hover:not(:disabled) { border-color: #0958d9; background: #0958d9; }
|
||||
.dim-panel .dim-credentialButton { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 10px; border: 1px solid #86909c; border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 5%); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; }
|
||||
.dim-panel .dim-actionIcon { width: 15px; height: 15px; flex: 0 0 15px; }
|
||||
.dim-panel .dim-credentialButton:hover:not(:disabled) { border-color: #4e5969; background: var(--dsw-alias-fill-tertiary, #f7f8fa); }
|
||||
.dim-panel .dim-credentialButton[aria-pressed="true"] { border-color: #4e5969; background: var(--dsw-alias-fill-secondary, #f2f3f5); box-shadow: inset 0 0 0 1px rgb(78 89 105 / 8%); }
|
||||
.dim-panel .bxf-headingTools .dim-onlineBadge, .dim-panel .dxw-tools .dim-onlineBadge, .dim-panel .ddt-tools .dim-onlineBadge { flex: none; min-height: 30px; display: inline-flex; align-items: center; gap: 0; padding: 0 11px; border: 0; border-radius: 999px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-fill-secondary, #f2f3f5); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; white-space: nowrap; }
|
||||
.dim-panel .dim-channelPage { width: 100%; max-width: none; display: flex; flex-direction: column; gap: 18px; padding: 0 0 24px; color: var(--dsw-alias-label-primary, #1f2329); box-sizing: border-box; }
|
||||
.dim-panel .dim-surfaceCard { position: relative; 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%); }
|
||||
.dim-panel .dim-surfaceCard::before { display: none; }
|
||||
.dim-panel .dim-surfaceBody { padding: 24px; }
|
||||
.dim-panel .dim-credentialPanel { display: grid; gap: 18px; padding: 20px; }
|
||||
.dim-panel .dim-credentialTitle { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: 1.35; font-weight: 650; }
|
||||
.dim-panel .dim-credentialForm { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 12px; }
|
||||
.dim-panel .dim-credentialField { min-width: 0; display: grid; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; font-weight: 560; }
|
||||
.dim-panel .dim-credentialField input { width: 100%; min-width: 0; height: 38px; padding: 0 11px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; transition: border-color .16s ease, box-shadow .16s ease; }
|
||||
.dim-panel .dim-credentialField input:focus { border-color: #4e5969; box-shadow: 0 0 0 3px rgb(78 89 105 / 10%); }
|
||||
.dim-panel .dim-credentialField input::placeholder { color: var(--dsw-alias-label-tertiary, #8f959e); font-family: inherit; }
|
||||
.dim-panel .dim-credentialError, .dim-panel .dim-credentialActions { grid-column: 1 / -1; }
|
||||
.dim-panel .dim-credentialError { margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; }
|
||||
.dim-panel .dim-credentialActions { margin-top: 0; }
|
||||
.dim-panel .dim-listSection { display: flex; flex-direction: column; gap: 0; }
|
||||
.dim-panel .dim-listHeading { min-height: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 2px 0 9px; padding: 0; }
|
||||
.dim-panel .dim-listHeading h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: normal; font-weight: 650; }
|
||||
.dim-panel .dim-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: normal; }
|
||||
.dim-panel .dim-botList { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||
.dim-panel .dim-loadingView { padding: 38px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; }
|
||||
.dim-panel .dim-loadingView h3 { margin: 0 0 7px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: normal; font-weight: 650; }
|
||||
|
|
@ -101,7 +115,7 @@ const CSS = String.raw`
|
|||
.dim-panel .dim-confirm p { margin: 7px 0 0; color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.6; }
|
||||
.dim-panel .dim-cardFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-line-divider, #eef0f3); }
|
||||
.dim-panel .dim-cardSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; }
|
||||
.dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin-top: 0; }
|
||||
.dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin: 0 0 0 auto; }
|
||||
.dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; }
|
||||
.dim-panel .dim-cardActions .dim-cardAction:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-fill-tertiary, #f7f8fa); }
|
||||
.dim-panel .dim-cardActions .dim-cardAction[data-kind="danger"] { color: var(--dsw-alias-state-error-primary, #d54941); }
|
||||
|
|
@ -128,6 +142,8 @@ const CSS = String.raw`
|
|||
.dim-panel .ddt-headingCopy { display: none; }
|
||||
.dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); }
|
||||
@container (max-width: 680px) {
|
||||
.dim-panel .dim-credentialForm { grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-panel .dim-credentialError, .dim-panel .dim-credentialActions { grid-column: auto; }
|
||||
.dim-panel .dim-emptyView { min-height: 0; grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-panel .dim-emptyBrand { display: none; }
|
||||
.dim-panel .dim-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; gap: 24px; }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
bindCredentials: 'bot.bind-credentials',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
approveSender: 'bot.sender.approve',
|
||||
|
|
@ -38,6 +39,10 @@ function validId(value) {
|
|||
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
|
||||
function validCredential(value, maxLength) {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
|
||||
}
|
||||
|
||||
function payloadFailure(endpoint, payload) {
|
||||
if (!isRecord(payload)) return 'Payload must be an object.';
|
||||
if (endpoint === DINGTALK_ENDPOINTS.status) {
|
||||
|
|
@ -53,6 +58,13 @@ function payloadFailure(endpoint, payload) {
|
|||
? null
|
||||
: `${endpoint} requires an attemptId.`;
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) {
|
||||
return exactKeys(payload, ['clientId', 'clientSecret'])
|
||||
&& validCredential(payload.clientId, 256)
|
||||
&& validCredential(payload.clientSecret, 1024)
|
||||
? null
|
||||
: 'bot.bind-credentials requires Client ID and Client Secret.';
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId)
|
||||
? null
|
||||
|
|
@ -138,6 +150,7 @@ function assertController(controller) {
|
|||
'startProvisioning',
|
||||
'registrationStatus',
|
||||
'cancelProvisioning',
|
||||
'bindCredentials',
|
||||
'reconnectBot',
|
||||
'deleteBot',
|
||||
'approveSender',
|
||||
|
|
@ -187,6 +200,8 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|||
value = await controller.cancelProvisioning(payload.attemptId);
|
||||
if (!value) return badRequest('The provisioning attempt no longer exists.');
|
||||
value = sanitizePublic(value);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) {
|
||||
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ function safeOpaqueId(value) {
|
|||
return typeof value === 'string' && SAFE_ID.test(value);
|
||||
}
|
||||
|
||||
function validCredential(value, maxLength) {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
|
||||
}
|
||||
|
||||
function publicError(error) {
|
||||
if (!error || typeof error !== 'object') return null;
|
||||
const code = typeof error.code === 'string' && Object.hasOwn(PUBLIC_ERROR_MESSAGES, error.code)
|
||||
|
|
@ -233,6 +237,13 @@ function validPayload(endpoint, payload) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
||||
return hasOnlyKeys(payload, new Set(['appId', 'appSecret']))
|
||||
&& validCredential(payload.appId, 256)
|
||||
&& validCredential(payload.appSecret, 1024)
|
||||
? null
|
||||
: 'Credential binding requires App ID and App Secret.';
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.pollProvisioning
|
||||
|| endpoint === FEISHU_ENDPOINTS.cancelProvisioning) {
|
||||
return hasOnlyKeys(payload, new Set(['attemptId'])) && safeOpaqueId(payload.attemptId)
|
||||
|
|
@ -393,6 +404,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|||
if (url) qrCache.delete(url);
|
||||
attemptQr.delete(payload.attemptId);
|
||||
value = { status: 'failed', message: 'Registration was cancelled.' };
|
||||
} else if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
||||
if (typeof controller.bindCredentials !== 'function') {
|
||||
throw new Error('Credential binding is unavailable');
|
||||
}
|
||||
value = await toPublicFeishuStatus(
|
||||
await controller.bindCredentials(payload),
|
||||
{ encodeQr: cachedEncodeQr },
|
||||
);
|
||||
} else if (endpoint === FEISHU_ENDPOINTS.testConnection) {
|
||||
const current = await controller.status();
|
||||
const alreadyConnected = current?.connected === true
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const QQ_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
bindCredentials: 'bot.bind-credentials',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
|
@ -27,6 +28,10 @@ function validId(value) {
|
|||
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
|
||||
function validCredential(value, maxLength) {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
|
||||
}
|
||||
|
||||
function payloadFailure(endpoint, payload) {
|
||||
if (!isRecord(payload)) return 'Payload must be an object.';
|
||||
if (endpoint === QQ_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
|
||||
|
|
@ -38,6 +43,12 @@ function payloadFailure(endpoint, payload) {
|
|||
return exactKeys(payload, ['attemptId']) && validId(payload.attemptId)
|
||||
? null : `${endpoint} requires an attemptId.`;
|
||||
}
|
||||
if (endpoint === QQ_ENDPOINTS.bindCredentials) {
|
||||
return exactKeys(payload, ['appId', 'appSecret'])
|
||||
&& validCredential(payload.appId, 256)
|
||||
&& validCredential(payload.appSecret, 1024)
|
||||
? null : 'bot.bind-credentials requires AppID and AppSecret.';
|
||||
}
|
||||
if (endpoint === QQ_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
|
||||
}
|
||||
|
|
@ -76,7 +87,7 @@ async function publicStatus(status, encodeQr) {
|
|||
}
|
||||
|
||||
export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
||||
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
|
||||
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'bindCredentials', 'reconnectBot', 'deleteBot']) {
|
||||
if (typeof controller?.[method] !== 'function') throw new TypeError(`A complete QQ controller is required (${method})`);
|
||||
}
|
||||
const qrCache = new Map();
|
||||
|
|
@ -104,6 +115,8 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
|||
value = await withEncodedQr(current, cachedEncode);
|
||||
} else if (endpoint === QQ_ENDPOINTS.cancelProvisioning) {
|
||||
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
|
||||
} else if (endpoint === QQ_ENDPOINTS.bindCredentials) {
|
||||
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
|
||||
} else if (endpoint === QQ_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
|
|||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
bindCredentials: 'bot.bind-credentials',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
|
@ -27,6 +28,10 @@ function validId(value) {
|
|||
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
|
||||
function validCredential(value, maxLength) {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
|
||||
}
|
||||
|
||||
function payloadFailure(endpoint, payload) {
|
||||
if (!isRecord(payload)) return 'Payload must be an object.';
|
||||
if (endpoint === WECOM_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
|
||||
|
|
@ -38,6 +43,12 @@ function payloadFailure(endpoint, payload) {
|
|||
return exactKeys(payload, ['attemptId']) && validId(payload.attemptId)
|
||||
? null : `${endpoint} requires an attemptId.`;
|
||||
}
|
||||
if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
|
||||
return exactKeys(payload, ['botId', 'secret'])
|
||||
&& validCredential(payload.botId, 512)
|
||||
&& validCredential(payload.secret, 1024)
|
||||
? null : 'bot.bind-credentials requires Bot ID and Secret.';
|
||||
}
|
||||
if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
|
||||
}
|
||||
|
|
@ -76,7 +87,7 @@ async function publicStatus(status, encodeQr) {
|
|||
}
|
||||
|
||||
export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
||||
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
|
||||
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'bindCredentials', 'reconnectBot', 'deleteBot']) {
|
||||
if (typeof controller?.[method] !== 'function') {
|
||||
throw new TypeError(`A complete Enterprise WeChat controller is required (${method})`);
|
||||
}
|
||||
|
|
@ -109,6 +120,8 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
|
|||
value = await withEncodedQr(current, cachedEncode);
|
||||
} else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) {
|
||||
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
|
||||
} else if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
|
||||
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
|
||||
} else if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
|
||||
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -280,6 +280,50 @@ export class DingtalkController {
|
|||
}
|
||||
}
|
||||
|
||||
/** Binds one DingTalk application with an existing Client ID and Client Secret. */
|
||||
async bindCredentials({ clientId, clientSecret } = {}) {
|
||||
if (this.#closed) throw new Error('dsh-dingtalk controller is closed');
|
||||
const normalizedClientId = cleanString(clientId);
|
||||
const normalizedSecret = cleanString(clientSecret);
|
||||
if (!normalizedClientId || !normalizedSecret) {
|
||||
throw new TypeError('DingTalk Client ID and Client Secret are required');
|
||||
}
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
if (this.#closed) throw new Error('dsh-dingtalk controller is closed');
|
||||
const identity = deriveDingtalkBotIdentity(normalizedClientId);
|
||||
await this.#withBotTransition(identity.botId, async () => {
|
||||
if (this.#closed) throw abortError();
|
||||
const previousConfig = this.#configStore.getByClientId(normalizedClientId);
|
||||
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
||||
if (this.#closed) throw abortError();
|
||||
const config = {
|
||||
botId: identity.botId,
|
||||
clientId: normalizedClientId,
|
||||
secretRef: identity.secretRef,
|
||||
approvedSenders: previousConfig?.approvedSenders ?? [],
|
||||
};
|
||||
await this.#credentials.set(identity.secretRef, normalizedSecret);
|
||||
try {
|
||||
await this.#configStore.save(config);
|
||||
} catch (error) {
|
||||
await this.#restoreCredential(identity.secretRef, previousSecret);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(config, normalizedSecret);
|
||||
this.#errors.delete(identity.botId);
|
||||
} catch {
|
||||
this.#errors.set(
|
||||
identity.botId,
|
||||
safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
|
||||
);
|
||||
this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready');
|
||||
}
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
/** Polls one QR registration without exposing its device code or returned secret. */
|
||||
async registrationStatus(attemptId) {
|
||||
const record = this.#attempts.get(attemptId);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export function isBotSender(event) {
|
|||
|
||||
export function isAllowedSender(event, allowedOpenIds) {
|
||||
if (!allowedOpenIds || allowedOpenIds.size === 0) return false;
|
||||
if (allowedOpenIds.has('*')) return true;
|
||||
const senderOpenId = event?.sender?.sender_id?.open_id;
|
||||
return typeof senderOpenId === 'string' && allowedOpenIds.has(senderOpenId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const ACTIVE_REGISTRATION_STATES = new Set([
|
|||
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
|
||||
]);
|
||||
const MUTABLE_REGISTRATION_STATES = new Set([...ACTIVE_REGISTRATION_STATES, 'saving']);
|
||||
const ALL_VISIBLE_SENDERS = '*';
|
||||
|
||||
function idleConnection() {
|
||||
return {
|
||||
|
|
@ -227,6 +228,75 @@ export class MultiBotDshFeishuController {
|
|||
});
|
||||
}
|
||||
|
||||
async bindCredentials({ appId, appSecret, domain = 'feishu' } = {}) {
|
||||
this.#assertOpen();
|
||||
const normalizedAppId = typeof appId === 'string' ? appId.trim() : '';
|
||||
const normalizedSecret = typeof appSecret === 'string' ? appSecret.trim() : '';
|
||||
const normalizedDomain = domain === 'lark' ? 'lark' : 'feishu';
|
||||
if (!normalizedAppId || !normalizedSecret) {
|
||||
throw new TypeError('Feishu App ID and App Secret are required');
|
||||
}
|
||||
|
||||
return this.#serializeConfig(async () => {
|
||||
this.#assertOpen();
|
||||
const bot = await this.#verifyApp({
|
||||
appId: normalizedAppId,
|
||||
appSecret: normalizedSecret,
|
||||
domain: normalizedDomain,
|
||||
});
|
||||
this.#assertOpen();
|
||||
const existing = this.#configStore.list().find(
|
||||
(candidate) => candidate.appId === normalizedAppId,
|
||||
);
|
||||
const botId = existing?.id ?? this.#createBotId();
|
||||
if (typeof botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(botId)
|
||||
|| (!existing && this.#configStore.getBot(botId))) {
|
||||
throw new Error('Bot id generator returned an invalid or duplicate id');
|
||||
}
|
||||
const secretRef = existing?.secretRef ?? secretRefFor(botId);
|
||||
const previousSecret = await this.#credentials.resolve(secretRef).catch(() => undefined);
|
||||
const config = {
|
||||
...existing,
|
||||
id: botId,
|
||||
appId: normalizedAppId,
|
||||
secretRef,
|
||||
ownerOpenIds: existing?.ownerOpenIds?.length
|
||||
? existing.ownerOpenIds
|
||||
: [ALL_VISIBLE_SENDERS],
|
||||
domain: normalizedDomain,
|
||||
botName: bot.name,
|
||||
botOpenId: bot.openId,
|
||||
activated: bot.activated,
|
||||
deletionPending: false,
|
||||
connectedAt: new Date().toISOString(),
|
||||
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.#credentials.set(secretRef, normalizedSecret);
|
||||
let saved;
|
||||
try {
|
||||
saved = await this.#configStore.saveBot(config);
|
||||
} catch (error) {
|
||||
await this.#restoreCredential(secretRef, previousSecret);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.#withBotTransition(botId, async () => {
|
||||
try {
|
||||
await this.#startRuntime(saved, normalizedSecret);
|
||||
this.#botErrors.delete(botId);
|
||||
} catch {
|
||||
this.#botErrors.set(botId, {
|
||||
code: 'connection_failed',
|
||||
message: '机器人已经绑定,但长连接未就绪,请点击重试。',
|
||||
});
|
||||
}
|
||||
});
|
||||
this.#touch();
|
||||
return this.status(botId);
|
||||
});
|
||||
}
|
||||
|
||||
async reconnectBot(botId) {
|
||||
this.#assertOpen();
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export class QqHarnessBridge {
|
|||
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (sender !== this.#ownerUserOpenid) {
|
||||
if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
|
||||
this.#status.messagesRejected += 1;
|
||||
this.#status.lastRejectedAt = new Date().toISOString();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -172,6 +172,48 @@ export class QqController {
|
|||
return publicAttempt(this.#attempts.get(attemptId));
|
||||
}
|
||||
|
||||
async bindCredentials({ appId, appSecret } = {}) {
|
||||
if (this.#closed) throw new Error('QQ controller is closed');
|
||||
const normalizedAppId = cleanString(appId);
|
||||
const normalizedSecret = cleanString(appSecret);
|
||||
if (!normalizedAppId || !normalizedSecret) {
|
||||
throw new TypeError('QQ AppID and AppSecret are required');
|
||||
}
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
if (this.#closed) throw new Error('QQ controller is closed');
|
||||
const identity = deriveQqBotIdentity(normalizedAppId);
|
||||
await this.#withBotTransition(identity.botId, async () => {
|
||||
if (this.#closed) throw new Error('QQ controller is closed');
|
||||
const previousConfig = this.#configStore.getByAppId(normalizedAppId);
|
||||
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
||||
if (this.#closed) throw new Error('QQ controller is closed');
|
||||
const config = {
|
||||
botId: identity.botId,
|
||||
appId: normalizedAppId,
|
||||
secretRef: identity.secretRef,
|
||||
ownerUserOpenid: previousConfig?.ownerUserOpenid ?? '*',
|
||||
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
||||
connectedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.#credentials.set(identity.secretRef, normalizedSecret);
|
||||
try {
|
||||
await this.#configStore.save(config);
|
||||
} catch (error) {
|
||||
await this.#restoreCredential(identity.secretRef, previousSecret);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(config, normalizedSecret);
|
||||
this.#errors.delete(identity.botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(identity.botId, safeError('connection-failed', 'QQ 机器人已绑定,消息连接暂未就绪。'));
|
||||
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error);
|
||||
}
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async cancelProvisioning(attemptId) {
|
||||
const record = this.#attempts.get(attemptId);
|
||||
if (!record) return null;
|
||||
|
|
@ -281,6 +323,7 @@ export class QqController {
|
|||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
await Promise.allSettled([...this.#transitions.values()]);
|
||||
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
||||
}
|
||||
|
||||
|
|
@ -358,7 +401,9 @@ export class QqController {
|
|||
}
|
||||
|
||||
async #startRuntime(config, appSecret) {
|
||||
if (this.#closed) throw new Error('QQ controller is closed');
|
||||
await this.#stopRuntime(config.botId);
|
||||
if (this.#closed) throw new Error('QQ controller is closed');
|
||||
const runtime = await this.#createRuntime({ botId: config.botId, config, appSecret });
|
||||
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
||||
throw new TypeError('createRuntime returned an invalid QQ runtime');
|
||||
|
|
|
|||
|
|
@ -113,7 +113,8 @@ export class QqRuntime {
|
|||
});
|
||||
bot.use?.(this.#typingMiddleware({
|
||||
keepAlive: true,
|
||||
predicate: (ctx) => ctx?.message?.senderId === this.#config.ownerUserOpenid,
|
||||
predicate: (ctx) => this.#config.ownerUserOpenid === '*'
|
||||
|| ctx?.message?.senderId === this.#config.ownerUserOpenid,
|
||||
}));
|
||||
|
||||
const controller = new AbortController();
|
||||
|
|
|
|||
|
|
@ -161,6 +161,50 @@ export class WecomController {
|
|||
return publicAttempt(record);
|
||||
}
|
||||
|
||||
async bindCredentials({ botId, secret } = {}) {
|
||||
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
||||
const remoteBotId = cleanString(botId);
|
||||
const normalizedSecret = cleanString(secret);
|
||||
if (!remoteBotId || !normalizedSecret) {
|
||||
throw new TypeError('Enterprise WeChat Bot ID and Secret are required');
|
||||
}
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
||||
const identity = deriveWecomBotIdentity(remoteBotId);
|
||||
await this.#withBotTransition(identity.botId, async () => {
|
||||
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
||||
const previousConfig = this.#configStore.getByRemoteBotId(remoteBotId);
|
||||
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
||||
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
||||
const config = {
|
||||
botId: identity.botId,
|
||||
remoteBotId,
|
||||
secretRef: identity.secretRef,
|
||||
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
||||
connectedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.#credentials.set(identity.secretRef, normalizedSecret);
|
||||
try {
|
||||
await this.#configStore.save(config);
|
||||
} catch (error) {
|
||||
await this.#restoreCredential(identity.secretRef, previousSecret);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(config, normalizedSecret);
|
||||
this.#errors.delete(identity.botId);
|
||||
} catch {
|
||||
this.#errors.set(
|
||||
identity.botId,
|
||||
safeError('connection-failed', '企业微信机器人已绑定,消息连接暂未就绪。'),
|
||||
);
|
||||
this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} credential connection failed`);
|
||||
}
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async cancelProvisioning(attemptId) {
|
||||
const record = this.#attempts.get(attemptId);
|
||||
if (!record) return null;
|
||||
|
|
@ -268,6 +312,7 @@ export class WecomController {
|
|||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
await Promise.allSettled([...this.#transitions.values()]);
|
||||
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +413,9 @@ export class WecomController {
|
|||
}
|
||||
|
||||
async #startRuntime(config, secret) {
|
||||
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
||||
await this.#stopRuntime(config.botId);
|
||||
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
||||
const runtime = await this.#createRuntime({ botId: config.botId, config, secret });
|
||||
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
||||
throw new TypeError('createRuntime returned an invalid Enterprise WeChat runtime');
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
|
|||
beginProvisioning: 'provision.begin',
|
||||
pollProvisioning: 'provision.poll',
|
||||
cancelProvisioning: 'provision.cancel',
|
||||
bindCredentials: 'bot.bind-credentials',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -148,6 +148,30 @@ test('successful QR poll stores secret then config then starts runtime without p
|
|||
await controller.close();
|
||||
});
|
||||
|
||||
test('manual DingTalk Client ID and Client Secret binding stores credentials off the public plane', async () => {
|
||||
const events = [];
|
||||
const credentials = credentialsFixture(events);
|
||||
const configs = configFixture([], events);
|
||||
const runtimes = runtimeFactory({ events });
|
||||
const controller = new DingtalkController({
|
||||
deviceAuth: successfulDeviceAuth(),
|
||||
credentials: credentials.provider,
|
||||
configStore: configs.store,
|
||||
createRuntime: runtimes.createRuntime,
|
||||
clock: () => 1_000,
|
||||
});
|
||||
|
||||
const status = await controller.bindCredentials({
|
||||
clientId: 'manual-ding-client',
|
||||
clientSecret: 'manual-ding-secret',
|
||||
});
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal([...credentials.values.values()][0], 'manual-ding-secret');
|
||||
assert.equal(runtimes.runtimes[0].clientSecret, 'manual-ding-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /manual-ding-client|manual-ding-secret|secretRef/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('scanning the same client ID is idempotent and replaces its one runtime', async () => {
|
||||
const events = [];
|
||||
const credentials = credentialsFixture(events);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ function controller() {
|
|||
startProvisioning() {},
|
||||
registrationStatus() {},
|
||||
cancelProvisioning() {},
|
||||
bindCredentials() {},
|
||||
reconnectBot() {},
|
||||
deleteBot() {},
|
||||
approveSender() {},
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function controller(overrides = {}) {
|
|||
}),
|
||||
registrationStatus: async () => ({ attemptId: 'attempt_1', status: 'pending' }),
|
||||
cancelProvisioning: async () => ({ attemptId: 'attempt_1', status: 'cancelled' }),
|
||||
bindCredentials: async () => ({ bots: [] }),
|
||||
reconnectBot: async () => ({ bots: [] }),
|
||||
deleteBot: async () => ({ bots: [] }),
|
||||
approveSender: async () => ({ bots: [] }),
|
||||
|
|
@ -76,6 +77,24 @@ test('RPC validates mutating requests before invoking the controller', async ()
|
|||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('credential RPC accepts Client ID fields while keeping Client Secret host-only', async () => {
|
||||
let received;
|
||||
const handler = createDingtalkRpcHandler(controller({
|
||||
bindCredentials: async (payload) => {
|
||||
received = payload;
|
||||
return { bots: [], clientSecret: payload.clientSecret };
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await handler(DINGTALK_ENDPOINTS.bindCredentials, {
|
||||
clientId: 'manual-client', clientSecret: 'manual-secret',
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(received, { clientId: 'manual-client', clientSecret: 'manual-secret' });
|
||||
assert.doesNotMatch(JSON.stringify(result), /manual-secret|clientSecret/);
|
||||
assert.equal((await handler(DINGTALK_ENDPOINTS.bindCredentials, { clientId: 'manual-client' })).ok, false);
|
||||
});
|
||||
|
||||
test('RPC is registered for loopback clients only', () => {
|
||||
const registrations = [];
|
||||
const dispose = () => {};
|
||||
|
|
|
|||
|
|
@ -47,4 +47,5 @@ test('isAllowedSender enforces an open-id allowlist', () => {
|
|||
assert.equal(isAllowedSender(event, new Set()), false);
|
||||
assert.equal(isAllowedSender(event, new Set(['ou_allowed'])), true);
|
||||
assert.equal(isAllowedSender(event, new Set(['ou_other'])), false);
|
||||
assert.equal(isAllowedSender(event, new Set(['*'])), true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -143,6 +143,23 @@ async function completeScan(fx, result) {
|
|||
return fx.controller.registrationStatus(attemptId);
|
||||
}
|
||||
|
||||
test('manual Feishu credentials are verified, stored host-side, and use app visibility for access', async () => {
|
||||
const fx = fixture({ createBotIds: ['bot_manual'] });
|
||||
|
||||
const status = await fx.controller.bindCredentials({
|
||||
appId: 'cli_manual',
|
||||
appSecret: 'manual-private-secret',
|
||||
});
|
||||
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(fx.configStore.bots.length, 1);
|
||||
assert.deepEqual(fx.configStore.bots[0].ownerOpenIds, ['*']);
|
||||
assert.equal(fx.values.get(fx.configStore.bots[0].secretRef), 'manual-private-secret');
|
||||
assert.equal(fx.runtimes.get('bot_manual')[0].appSecret, 'manual-private-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /manual-private-secret|ownerOpenIds|secretRef/);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('initialization isolates failures and starts every bot with available credentials', async () => {
|
||||
const missing = bot('bot_missing', 'missing');
|
||||
const healthy = bot('bot_healthy', 'healthy');
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
|||
return current;
|
||||
},
|
||||
reconnect: async () => { calls.push('test'); return current; },
|
||||
bindCredentials: async ({ appId, appSecret }) => {
|
||||
calls.push(`bind:${appId}:${appSecret}`);
|
||||
return current;
|
||||
},
|
||||
disconnect: async () => { calls.push('disconnect'); return status(); },
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
|
@ -155,6 +159,14 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
|||
const tested = await fx.registration.handler(FEISHU_ENDPOINTS.testConnection, {}, signal());
|
||||
assert.equal(tested.ok, true);
|
||||
|
||||
const bound = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.bindCredentials,
|
||||
{ appId: 'cli_manual', appSecret: 'manual-private-secret' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(bound.ok, true);
|
||||
assert.doesNotMatch(JSON.stringify(bound), /manual-private-secret|appSecret/);
|
||||
|
||||
const cancelled = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.cancelProvisioning,
|
||||
{ attemptId: '7' },
|
||||
|
|
@ -169,7 +181,9 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
|||
signal(),
|
||||
);
|
||||
assert.equal(disconnected.ok, true);
|
||||
assert.deepEqual(calls, ['begin', 'test', 'cancel', 'disconnect']);
|
||||
assert.deepEqual(calls, [
|
||||
'begin', 'test', 'bind:cli_manual:manual-private-secret', 'cancel', 'disconnect',
|
||||
]);
|
||||
|
||||
const attemptedSecret = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.beginProvisioning,
|
||||
|
|
|
|||
|
|
@ -79,3 +79,25 @@ test('QQ bridge accepts only the scanner and requires an at-message event in gro
|
|||
assert.equal(asks, 0);
|
||||
assert.equal(bridge.status.messagesRejected, 1);
|
||||
});
|
||||
|
||||
test('QQ credential-bound bots accept senders within the platform visibility scope', async () => {
|
||||
let asks = 0;
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: { sendText: async () => {} },
|
||||
ownerUserOpenid: '*',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => { asks += 1; return 'ok'; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
},
|
||||
});
|
||||
await bridge.accept(message({ messageId: 'visible', senderId: 'visible-user' }));
|
||||
assert.equal(asks, 1);
|
||||
assert.equal(bridge.status.messagesRejected, 0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ test('QQ settings uses the shared compact channel toolbar', () => {
|
|||
}));
|
||||
assert.match(markup, /class="ddt-page dqq-page dim-channelPage"/);
|
||||
assert.match(markup, /class="ddt-button dim-scanButton"/);
|
||||
assert.match(markup, />扫码绑定QQ机器人</);
|
||||
assert.match(markup, /aria-label="扫码接入 QQ 机器人"/);
|
||||
assert.match(markup, /class="dim-actionIcon"[^]*扫码接入机器人/);
|
||||
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
|
||||
});
|
||||
|
||||
|
|
@ -36,5 +37,16 @@ test('QQ bot cards match the shared two-metric card treatment', () => {
|
|||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.match(markup, />消息通道<[^]*>最近检查</);
|
||||
assert.match(markup, />检查连接<[^]*>移除接入</);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复/);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|QQ WebSocket 长连接运行正常/);
|
||||
|
||||
const offlineMarkup = renderToStaticMarkup(React.createElement(AccountCard, {
|
||||
account: {
|
||||
botId: 'qq_bot', connected: false, state: 'error',
|
||||
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
|
||||
health: { summary: '连接失败,请检查凭据', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
|
||||
}));
|
||||
assert.match(offlineMarkup, /class="ddt-summary dim-cardSummary">连接失败,请检查凭据</);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,42 @@ test('QQ QR success stores the secret off-config and starts a scanner-owned runt
|
|||
qr.resolve();
|
||||
});
|
||||
|
||||
test('QQ AppID and AppSecret binding stores the secret and accepts the platform visibility scope', async () => {
|
||||
const values = new Map();
|
||||
const configs = [];
|
||||
let runtimeArgs;
|
||||
const controller = new QqController({
|
||||
qrAuth: { start() { return () => {}; } },
|
||||
credentials: {
|
||||
resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined,
|
||||
set: async (ref, value) => values.set(ref, value),
|
||||
unset: async (ref) => values.delete(ref),
|
||||
},
|
||||
configStore: {
|
||||
list: () => [...configs],
|
||||
get: (id) => configs.find((value) => value.botId === id) ?? null,
|
||||
getByAppId: (id) => configs.find((value) => value.appId === id) ?? null,
|
||||
save: async (value) => { configs.splice(0, configs.length, value); },
|
||||
remove: async () => null,
|
||||
},
|
||||
createRuntime: async (args) => {
|
||||
runtimeArgs = args;
|
||||
return {
|
||||
status: { ready: true, qqConnectionState: 'connected', harnessReachable: true },
|
||||
start: async () => {}, stop: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const status = await controller.bindCredentials({ appId: 'manual-app', appSecret: 'manual-secret' });
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(configs[0].ownerUserOpenid, '*');
|
||||
assert.equal(values.get(configs[0].secretRef), 'manual-secret');
|
||||
assert.equal(runtimeArgs.appSecret, 'manual-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /manual-secret|ownerUserOpenid|secretRef/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('QQ RPC turns the host-only QR URL into an image and strips credential fields', async () => {
|
||||
const handler = createQqRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
|
|
@ -63,6 +99,7 @@ test('QQ RPC turns the host-only QR URL into an image and strips credential fiel
|
|||
}),
|
||||
registrationStatus: () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async () => ({}),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
|
|
@ -71,3 +108,23 @@ test('QQ RPC turns the host-only QR URL into an image and strips credential fiel
|
|||
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
|
||||
assert.doesNotMatch(JSON.stringify(result), /q\.qq\.com|never-public|ownerUserOpenid|appSecret/);
|
||||
});
|
||||
|
||||
test('QQ credential RPC validates the pair and never returns AppSecret', async () => {
|
||||
let received;
|
||||
const handler = createQqRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({}), registrationStatus: () => null,
|
||||
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}), deleteBot: async () => ({}),
|
||||
bindCredentials: async (payload) => {
|
||||
received = payload;
|
||||
return { bots: [], totals: { configured: 0, connected: 0 }, appSecret: payload.appSecret };
|
||||
},
|
||||
});
|
||||
const result = await handler(QQ_ENDPOINTS.bindCredentials, {
|
||||
appId: 'manual-app', appSecret: 'manual-secret',
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(received, { appId: 'manual-app', appSecret: 'manual-secret' });
|
||||
assert.doesNotMatch(JSON.stringify(result), /manual-secret|appSecret/);
|
||||
assert.equal((await handler(QQ_ENDPOINTS.bindCredentials, { appId: 'manual-app' })).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ test('Enterprise WeChat settings uses the shared compact channel toolbar', () =>
|
|||
}));
|
||||
assert.match(markup, /class="ddt-page dwecom-page dim-channelPage"/);
|
||||
assert.match(markup, /class="ddt-button dim-scanButton"/);
|
||||
assert.match(markup, />扫码绑定企业微信机器人</);
|
||||
assert.match(markup, /aria-label="扫码接入企业微信机器人"/);
|
||||
assert.match(markup, /class="dim-actionIcon"[^]*扫码接入机器人/);
|
||||
assert.doesNotMatch(markup, /凭据仅保存在本机|role="switch"|type="checkbox"/);
|
||||
});
|
||||
|
||||
|
|
@ -39,5 +40,5 @@ test('Enterprise WeChat bot cards match the shared two-metric card treatment', (
|
|||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.match(markup, />消息通道<[^]*>最近检查</);
|
||||
assert.match(markup, />检查连接<[^]*>移除接入</);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复/);
|
||||
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|企业微信 WebSocket 长连接运行正常/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,40 @@ test('Enterprise WeChat QR success stores Secret off-config and starts its runti
|
|||
assert.doesNotMatch(JSON.stringify(status), /private-secret|secretRef|remote-bot|host-only-code/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts its runtime', async () => {
|
||||
const values = new Map();
|
||||
const configs = [];
|
||||
let runtimeArgs;
|
||||
const controller = new WecomController({
|
||||
qrAuth: { start: async () => ({}), poll: async () => ({ status: 'waiting' }) },
|
||||
credentials: {
|
||||
resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined,
|
||||
set: async (ref, value) => values.set(ref, value),
|
||||
unset: async (ref) => values.delete(ref),
|
||||
},
|
||||
configStore: {
|
||||
list: () => [...configs],
|
||||
get: (id) => configs.find((value) => value.botId === id) ?? null,
|
||||
getByRemoteBotId: (id) => configs.find((value) => value.remoteBotId === id) ?? null,
|
||||
save: async (value) => { configs.splice(0, configs.length, value); },
|
||||
remove: async () => null,
|
||||
},
|
||||
createRuntime: async (args) => {
|
||||
runtimeArgs = args;
|
||||
return {
|
||||
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
|
||||
start: async () => {}, stop: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const status = await controller.bindCredentials({ botId: 'remote-manual', secret: 'manual-secret' });
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(values.get(configs[0].secretRef), 'manual-secret');
|
||||
assert.equal(runtimeArgs.secret, 'manual-secret');
|
||||
assert.doesNotMatch(JSON.stringify(status), /manual-secret|remote-manual|secretRef/);
|
||||
await controller.close();
|
||||
});
|
||||
|
||||
test('Enterprise WeChat RPC encodes the QR on Host and strips every authorization field', async () => {
|
||||
const handler = createWecomRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
|
|
@ -66,6 +100,7 @@ test('Enterprise WeChat RPC encodes the QR on Host and strips every authorizatio
|
|||
}),
|
||||
registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => ({}),
|
||||
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
reconnectBot: async () => ({}),
|
||||
deleteBot: async () => ({}),
|
||||
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
|
||||
|
|
@ -74,3 +109,23 @@ test('Enterprise WeChat RPC encodes the QR on Host and strips every authorizatio
|
|||
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
|
||||
assert.doesNotMatch(JSON.stringify(result), /work\.weixin|never-public|remoteBotId|scode|secret/);
|
||||
});
|
||||
|
||||
test('Enterprise WeChat credential RPC accepts official Bot ID fields and redacts Secret', async () => {
|
||||
let received;
|
||||
const handler = createWecomRpcHandler({
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
startProvisioning: async () => ({}), registrationStatus: async () => null,
|
||||
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}), deleteBot: async () => ({}),
|
||||
bindCredentials: async (payload) => {
|
||||
received = payload;
|
||||
return { bots: [], totals: { configured: 0, connected: 0 }, secret: payload.secret };
|
||||
},
|
||||
});
|
||||
const result = await handler(WECOM_ENDPOINTS.bindCredentials, {
|
||||
botId: 'remote-manual', secret: 'manual-secret',
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(received, { botId: 'remote-manual', secret: 'manual-secret' });
|
||||
assert.doesNotMatch(JSON.stringify(result), /manual-secret|"secret"/);
|
||||
assert.equal((await handler(WECOM_ENDPOINTS.bindCredentials, { botId: 'remote-manual' })).ok, false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import React from 'react';
|
|||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { IMSettingsTab } from '../plugin-src/client/index.js';
|
||||
import { CredentialBindingPanel } from '../plugin-src/client/credential-binding.js';
|
||||
import { DINGTALK_ENDPOINTS } from '../plugin-src/client/channels/dingtalk/api.js';
|
||||
import {
|
||||
AccountCard as DingtalkAccountCard,
|
||||
|
|
@ -23,6 +24,7 @@ import {
|
|||
AccountCard as WecomAccountCard,
|
||||
WecomSettingsTab,
|
||||
} from '../plugin-src/client/channels/wecom/index.js';
|
||||
import { QqSettingsTab } from '../plugin-src/client/channels/qq/index.js';
|
||||
|
||||
const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url);
|
||||
const FEISHU_STYLES_URL = new URL(
|
||||
|
|
@ -58,8 +60,13 @@ const WECOM_SOURCE_URL = new URL(
|
|||
'../plugin-src/client/channels/wecom/index.js',
|
||||
import.meta.url,
|
||||
);
|
||||
const QQ_SOURCE_URL = new URL(
|
||||
'../plugin-src/client/channels/qq/index.js',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test('IM settings renders five compact logo channel tabs without enable switches', () => {
|
||||
test('IM settings renders five compact logo channel tabs without enable switches', async () => {
|
||||
const styles = await readFile(STYLES_URL, 'utf8');
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
weixinRpcCall: async () => ({ ok: true, value: {} }),
|
||||
|
|
@ -81,6 +88,7 @@ test('IM settings renders five compact logo channel tabs without enable switches
|
|||
assert.match(markup, /dim-logoDingtalk/);
|
||||
assert.match(markup, /dim-logoWecom/);
|
||||
assert.match(markup, /dim-logoQq/);
|
||||
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 5);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
|
|
@ -130,6 +138,7 @@ test('Feishu bot cards place the application identifier under the bot name', asy
|
|||
assert.match(markup, /class="bxf-healthPill dim-botHealth"/);
|
||||
assert.match(markup, /<button[^>]*aria-label="检查连接今天是牢梁"[^>]*><span>检查连接<\/span><\/button>/);
|
||||
assert.match(markup, /class="bxf-connectedFooter dim-cardFooter"/);
|
||||
assert.doesNotMatch(markup, /dim-cardSummary|长连接运行正常/);
|
||||
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(markup, /连接状态:|bxf-divider/);
|
||||
assert.doesNotMatch(markup, /custom-bot-avatar/);
|
||||
|
|
@ -145,13 +154,60 @@ test('Feishu keeps its heading controls on one row without a plus icon', async (
|
|||
rpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
assert.match(markup, /class="bxf-button bxf-bindButton dim-scanButton"[^>]*><span>扫码绑定机器人<\/span><\/button>/);
|
||||
assert.match(markup, /aria-label="扫码接入飞书机器人"/);
|
||||
assert.match(markup, /class="dim-actionIcon"[^]*<span>扫码接入机器人<\/span>/);
|
||||
assert.doesNotMatch(markup, />添加机器人</);
|
||||
assert.match(styles, /\.bxf-headingTools \{[^}]*justify-content: space-between;[^}]*flex-wrap: nowrap;/);
|
||||
assert.match(styles, /@container \(max-width: 620px\)[^]*\.bxf-headingTools \{ gap: 6px; \}/);
|
||||
assert.doesNotMatch(styles, /\.bxf-headingTools \.bxf-button \{ margin-left: auto; \}/);
|
||||
});
|
||||
|
||||
test('credential binding is a distinct secondary action beside QR binding in four channels', async () => {
|
||||
const settings = [
|
||||
['飞书', FeishuSettingsTab],
|
||||
['QQ', QqSettingsTab],
|
||||
['钉钉', DingtalkSettingsTab],
|
||||
['企业微信', WecomSettingsTab],
|
||||
];
|
||||
for (const [channel, Component] of settings) {
|
||||
const markup = renderToStaticMarkup(React.createElement(Component, {
|
||||
rpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
const scanIndex = markup.indexOf('dim-scanButton');
|
||||
const credentialIndex = markup.indexOf('dim-credentialButton');
|
||||
assert.ok(scanIndex >= 0, `${channel} should render a QR button`);
|
||||
assert.ok(credentialIndex > scanIndex, `${channel} should place credential binding after QR binding`);
|
||||
assert.match(markup, /data-kind="credential"/);
|
||||
const credentialMarkup = markup.slice(credentialIndex, markup.indexOf('</button>', credentialIndex));
|
||||
assert.match(credentialMarkup, /dim-actionIcon/);
|
||||
assert.match(credentialMarkup, /手动接入/);
|
||||
}
|
||||
|
||||
const styles = await readFile(STYLES_URL, 'utf8');
|
||||
assert.match(styles, /\.dim-panel \.dim-bindActions \{[^}]*flex-wrap: nowrap;/);
|
||||
assert.match(styles, /\.dim-panel \.dim-credentialButton \{[^}]*border: 1px solid #86909c;[^}]*background: var\(--dsw-alias-bg-body, #fff\)/);
|
||||
assert.match(styles, /\.dim-panel \.dim-actionIcon \{[^}]*flex: 0 0 15px;/);
|
||||
assert.doesNotMatch(styles, /\.dim-panel \.dim-credentialPanel \{[^}]*border-left:/);
|
||||
});
|
||||
|
||||
test('credential form stays compact while using a protected password input', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(CredentialBindingPanel, {
|
||||
channel: '企业微信',
|
||||
identityLabel: 'Bot ID',
|
||||
identityPlaceholder: '填写 Bot ID',
|
||||
secretLabel: 'Secret',
|
||||
secretPlaceholder: '填写 Secret',
|
||||
onSubmit() {},
|
||||
onCancel() {},
|
||||
}));
|
||||
assert.match(markup, />Bot ID</);
|
||||
assert.match(markup, /type="password"/);
|
||||
assert.match(markup, /autoComplete="new-password"/i);
|
||||
assert.match(markup, />手动接入企业微信机器人</);
|
||||
assert.doesNotMatch(markup, /已有机器人应用|Harness 会校验凭据|可见范围|受保护的凭据存储/);
|
||||
assert.doesNotMatch(markup, /value="[^"]+"/);
|
||||
});
|
||||
|
||||
test('scan actions align left while online totals align right in every channel', async () => {
|
||||
const [imStyles, feishuStyles, weixinStyles, dingtalkStyles, wecomStyles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([
|
||||
readFile(STYLES_URL, 'utf8'),
|
||||
|
|
@ -179,10 +235,10 @@ test('scan actions align left while online totals align right in every channel',
|
|||
const weixinHeading = headingSource(weixinSource);
|
||||
const dingtalkHeading = headingSource(dingtalkSource);
|
||||
const wecomHeading = headingSource(wecomSource);
|
||||
assert.ok(feishuHeading.indexOf('扫码绑定机器人') < feishuHeading.indexOf('bxf-totalBadge'));
|
||||
assert.ok(weixinHeading.indexOf('扫码绑定微信') < weixinHeading.indexOf('dxw-badge'));
|
||||
assert.ok(dingtalkHeading.indexOf('扫码接入钉钉') < dingtalkHeading.indexOf('ddt-badge'));
|
||||
assert.ok(wecomHeading.indexOf('扫码绑定企业微信机器人') < wecomHeading.indexOf('ddt-badge'));
|
||||
assert.ok(feishuHeading.indexOf('扫码接入机器人') < feishuHeading.indexOf('bxf-totalBadge'));
|
||||
assert.ok(weixinHeading.indexOf('扫码接入机器人') < weixinHeading.indexOf('dxw-badge'));
|
||||
assert.ok(dingtalkHeading.indexOf('扫码接入机器人') < dingtalkHeading.indexOf('ddt-badge'));
|
||||
assert.ok(wecomHeading.indexOf('扫码接入机器人') < wecomHeading.indexOf('ddt-badge'));
|
||||
|
||||
for (const heading of [feishuHeading, weixinHeading, dingtalkHeading, wecomHeading]) {
|
||||
assert.match(heading, /dim-scanButton/);
|
||||
|
|
@ -205,6 +261,20 @@ test('channel headings omit the redundant local credential badge', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('bot list headings omit the total already shown by the online badge', async () => {
|
||||
const sources = await Promise.all([
|
||||
FEISHU_SOURCE_URL,
|
||||
WEIXIN_SOURCE_URL,
|
||||
DINGTALK_CLIENT_SOURCE_URL,
|
||||
WECOM_SOURCE_URL,
|
||||
QQ_SOURCE_URL,
|
||||
].map((url) => readFile(url, 'utf8')));
|
||||
|
||||
for (const source of sources) {
|
||||
assert.doesNotMatch(source, /length} 个/);
|
||||
}
|
||||
});
|
||||
|
||||
test('all channel settings states use the DingTalk page treatment', async () => {
|
||||
const [styles, feishuSource, weixinSource, dingtalkSource, wecomSource] = await Promise.all([
|
||||
readFile(STYLES_URL, 'utf8'),
|
||||
|
|
@ -277,6 +347,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
|
|||
assert.match(accountMarkup, /class="dxw-avatar dim-botAvatar"[^]*data-im-channel-logo="weixin"/);
|
||||
assert.match(accountMarkup, /class="dxw-health dim-botHealth"/);
|
||||
assert.match(accountMarkup, /class="dxw-accountFooter dim-cardFooter"/);
|
||||
assert.doesNotMatch(accountMarkup, /dim-cardSummary|微信消息长轮询运行正常/);
|
||||
assert.equal((accountMarkup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
|
||||
assert.equal((accountMarkup.match(/class="dxw-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(accountMarkup, /收到 \/ 回复/);
|
||||
|
|
@ -316,6 +387,7 @@ test('DingTalk bot cards omit the redundant received and replied metric', () =>
|
|||
assert.match(markup, /class="ddt-health dim-botHealth"/);
|
||||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
assert.match(markup, /class="ddt-accountFooter dim-cardFooter"/);
|
||||
assert.doesNotMatch(markup, /dim-cardSummary|Stream 长连接运行正常/);
|
||||
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
|
||||
assert.match(markup, />消息通道<[^]*>最近检查</);
|
||||
assert.doesNotMatch(markup, /收到 \/ 回复/);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue